"""SC Controller - Daemon class."""

from __future__ import annotations

import json
import logging
import os
import pkgutil
import signal
import stat
import subprocess
import sys
import threading
import time
import traceback
from socketserver import StreamRequestHandler, ThreadingMixIn, UnixStreamServer
from typing import TYPE_CHECKING

from scc import drivers
from scc.actions import Action
from scc.cemuhook_server import CemuhookServer
from scc.config import Config
from scc.constants import (
	CPAD,
	DAEMON_VERSION,
	DPAD,
	HapticPos,
	SCButtons,
	SCPads,
	SCSticks,
	SCTriggers,
)
from scc.controller import HapticData
from scc.custom import load_custom_module
from scc.device_monitor import create_device_monitor
from scc.gestures import GestureDetector
from scc.lib import xinput
from scc.lib import xwrappers as X
from scc.lib.daemon import Daemon
from scc.mapper import Mapper
from scc.menu_data import MenuData
from scc.parser import TalkingActionParser
from scc.poller import Poller
from scc.profile import Profile
from scc.scheduler import Scheduler
from scc.tools import clamp, find_binary, find_menu, find_profile, nameof, set_logging_level, shjoin, shsplit
from scc.uinput import CannotCreateUInputException

if TYPE_CHECKING:
	from _thread import LockType
	from collections.abc import Callable
	from subprocess import Popen
	from threading import Thread
	from typing import Never

	from gi.repository import Gio, GLib

	from scc.actions import AreaAction
	from scc.controller import Controller
	from scc.device_monitor import DeviceMonitor
	from scc.special_actions import (
		CemuHookAction,
		ChangeProfileAction,
		ClearOSDAction,
		DialogAction,
		GesturesAction,
		KeyboardAction,
		LedAction,
		MenuAction,
		OSDAction,
		ShellCommandAction,
		TurnOffAction,
	)

log = logging.getLogger("SCCDaemon")
tlog = logging.getLogger("Socket Thread")


class ThreadingUnixStreamServer(ThreadingMixIn, UnixStreamServer):
	daemon_threads: bool = True


class SCCDaemon(Daemon):
	def __init__(self, piddile: str, socket_file: str) -> None:
		self.profile_file: str
		set_logging_level(True, True)
		Daemon.__init__(self, piddile)
		Config()  # Generates ~/.config/scc and default config if needed
		self.started: bool = False
		self.exiting: bool = False
		self.socket_file: str = socket_file
		self.poller: Poller = Poller()
		self.dev_monitor: DeviceMonitor = create_device_monitor(self)
		self.scheduler: Scheduler = Scheduler()
		self.xdisplay = None
		self.sserver = None  # UnixStreamServer instance
		self.errors = []
		self.alone: bool = False  # Set by launching script from --alone flag
		self.custom_py_loaded: bool = False
		self.osd_daemon: Client | None = None
		self.default_profile: str | None = None
		self.autoswitch_daemon: Client | None = None
		# TODO: Use osd_ids for all menus
		self.osd_ids: dict[str, Action] = {}
		self.controllers: list[Controller] = []
		self.mainloops: list[Callable[[], None]] = [self._poll, self.scheduler.run]
		self.rescan_cbs: list[Callable[[], None]] = []
		self.on_exit_cbs: list[Callable[..., None]] = []
		self.subprocs: list[Subprocess] = []
		self.lock: LockType = threading.Lock()
		self.cemuhook: CemuhookServer | None = None
		self.default_mapper: Mapper | None = None
		self.free_mappers: list[Mapper] = []
		self.clients: set[Client] = set()
		self.cwd: str = os.getcwd()
		self._system_bus: Gio.DBusConnection | None = None
		self._prepare_for_sleep_subscription: int | None = None
		self._glib_context: GLib.MainContext | None = None

	def _poll(self) -> None:
		self.poller.poll(self.scheduler.get_poll_timeout())

	def init_sleep_monitor(self) -> None:
		"""Listen for systemd-logind suspend notifications."""
		try:
			from gi.repository import Gio, GLib

			self._system_bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
			self._glib_context = GLib.MainContext.default()
			self._prepare_for_sleep_subscription = self._system_bus.signal_subscribe(
				"org.freedesktop.login1",
				"org.freedesktop.login1.Manager",
				"PrepareForSleep",
				"/org/freedesktop/login1",
				None,
				Gio.DBusSignalFlags.NONE,
				self._on_prepare_for_sleep,
				None,
			)
			self.add_mainloop(self._dispatch_glib_events)
			self.add_on_exit(self._close_sleep_monitor)
		except Exception:
			log.exception("Failed to listen for system sleep events")

	def _on_prepare_for_sleep(
		self,
		_connection: Gio.DBusConnection,
		_sender_name: str | None,
		_object_path: str,
		_interface_name: str,
		_signal_name: str,
		parameters: GLib.Variant,
		_user_data: object | None,
	) -> None:
		sleeping = parameters.get_child_value(0).get_boolean()
		if sleeping:
			log.info("System going to sleep")
		else:
			log.info("System woke up")

	def _dispatch_glib_events(self) -> None:
		if self._glib_context is not None:
			while self._glib_context.pending():
				_ = self._glib_context.iteration(False)

	def _close_sleep_monitor(self, _daemon: SCCDaemon) -> None:
		if self._system_bus is not None and self._prepare_for_sleep_subscription is not None:
			self._system_bus.signal_unsubscribe(self._prepare_for_sleep_subscription)
		self._prepare_for_sleep_subscription = None
		self._system_bus = None
		self._glib_context = None

	def init_drivers(self) -> None:
		"""Search and initialize all controller drivers.

		See __init__.py in scc.drivers.
		"""
		log.debug("Initializing drivers...")
		cfg = Config()
		self._to_start = set()  # del-eted later by start_drivers
		to_init = []
		for importer, modname, ispkg in pkgutil.walk_packages(path=drivers.__path__, onerror=lambda x: None):
			if not ispkg and modname != "driver":
				if modname == "usb" or cfg["drivers"].get(modname):
					# 'usb' driver has to be always active
					mod = getattr(__import__(f"scc.drivers.{modname}").drivers, modname)
					if hasattr(mod, "init"):
						to_init.append(mod)
				else:
					log.warning("Skipping disabled driver '%s'", modname)

		from scc.drivers import MOD_INIT_ORDER as order

		index_fn = lambda n: order.index(n) if n in order else 1024
		sort_fn = lambda m: index_fn(m.__name__)

		for mod in sorted(to_init, key=sort_fn):
			if mod.init(self, cfg):
				if hasattr(mod, "start"):
					self._to_start.add(mod.start)

	def init_default_mapper(self) -> Mapper:
		"""default_mapper is persistent mapper assigned to first Controller instance.

		Even if all controllers are removed, this mapper stays active. This is
		needed so various stuff (mainlg GUI) doesn't need to check if there is
		any controller connected all the time.
		"""
		# But, despite all above, it's just mapper as every other :)
		return self.init_mapper()

	def set_default_profile(self, profile_file: str) -> None:
		"""Sets profile that is used for first available controller"""
		self.default_profile = profile_file

	def start_drivers(self) -> None:
		for s in self._to_start:
			s(self)
		del self._to_start

	# def stop_drivers(self) -> None:
	# 	for s in self.drivers_to_stop:
	# 		s(self)

	def get_poller(self) -> Poller:
		"""Returns poller that can be used for polling file descriptors"""
		return self.poller

	def get_device_monitor(self) -> DeviceMonitor:
		"""Returns device monitor that can be used to listen for device adding and removals"""
		return self.dev_monitor

	def get_scheduler(self) -> Scheduler:
		"""Returns scheduler instance"""
		return self.scheduler

	def add_mainloop(self, fn: Callable[[], None]) -> None:
		"""Adds function that is called in every mainloop iteration.

		Can be called only durring initialization, in driver 'init' method.
		"""
		if fn not in self.mainloops:
			self.mainloops.append(fn)

	def remove_mainloop(self, fn: Callable[[], None]) -> None:
		"""Removes function added by add_mainloop"""
		if fn in self.mainloops:
			self.mainloops.remove(fn)

	def add_on_exit(self, fn: Callable[..., None]) -> None:
		"""Adds function that is called just before daemon is stopped.

		Useful for cleanup.
		"""
		if fn not in self.on_exit_cbs:
			self.on_exit_cbs.append(fn)

	def add_on_rescan(self, fn: Callable[[], None]) -> None:
		"""Adds function that is called when `Rescan.` message is received."""
		if fn not in self.on_exit_cbs:
			self.rescan_cbs.append(fn)

	def _set_profile(self, mapper: Mapper, filename: str) -> None:
		# Called from socket server thread
		p = Profile(TalkingActionParser())
		p.load(filename).compress()
		self.profile_file = filename

		if mapper.profile.gyro and not p.gyro:
			# Turn off gyro sensor that was enabled but is no longer needed
			if mapper.get_controller():
				log.debug("Turning gyrosensor OFF")
				mapper.get_controller().set_gyro_enabled(False)
		elif not mapper.profile.gyro and p.gyro:
			# Turn on gyro sensor that was turned off, if profile has gyro action set
			if mapper.get_controller():
				log.debug("Turning gyrosensor ON")
				mapper.get_controller().set_gyro_enabled(True)
		# Cancel everything
		mapper.cancel_all()
		# Release all buttons
		mapper.release_virtual_buttons()
		# Reset mouse (issue #222)
		mapper.mouse.reset()

		# This last line kinda depends on GIL...
		mapper.profile = p
		# Re-apply all locks
		for c in self.clients:
			c.reaply_locks(self, mapper)
		if mapper.get_controller():
			self.send_profile_info(mapper.get_controller(), self._send_to_all)
		else:
			self.send_profile_info(None, self._send_to_all, mapper=mapper)

	def _send_to_all(self, message_str: bytes) -> None:
		"""Sends message to all connect clients.

		Should be called while lock is acquired.
		Message should be utf-8 encoded str.
		"""
		for client in self.clients:
			try:
				client.wfile.write(message_str)
			except Exception:
				pass

	def on_sa_turnoff(self, mapper: Mapper, action: TurnOffAction) -> None:
		"""Called when 'turnoff' action is used"""
		if mapper.get_controller():
			mapper.get_controller().turnoff()

	def on_sa_restart(self, *a) -> None:
		"""Called when 'restart' action is used"""
		with self.lock:
			for c in self.clients:
				c.close()
		os.system(f"{sys.executable} {sys.argv[0]} None restart &")

	def on_sa_led(self, mapper: Mapper, action: LedAction) -> None:
		"""Called when 'led' action is used"""
		if mapper.get_controller():
			mapper.get_controller().set_led_level(action.brightness)

	def on_sa_shell(self, mapper: Mapper, action: ShellCommandAction) -> Popen[bytes]:
		"""Called when 'shell' action is used"""
		return subprocess.Popen(action.command, shell=True)

	def on_sa_gestures(self, mapper: Mapper, action: GesturesAction, x, y, what: str) -> None:
		"""Called when 'gestures' action is used"""
		# TODO: Take up_direction from action
		gd = None
		with self.lock:
			if action.osd_enabled and self.osd_daemon:
				# When OSD is enabled, gesture detection is handled
				# by scc-osd-daemon.
				self.osd_daemon.gesture_action = action
				self._osd("gesture", "--controller", mapper.get_controller().get_id(), "--control-with", what)
				log.debug("Gesture detection request sent to scc-osd-daemon")
			else:
				# Otherwise it is handled internally
				up_direction = 0
				gd = self._start_gesture(
					mapper,
					what,
					up_direction,
					lambda gesture_string: action.gesture(mapper, gesture_string),
				)
		if gd:
			gd.enable()
			log.debug("Gesture detection started on %s", what)
			gd.whole(mapper, x, y, what)

	def on_sa_cemuhook(self, mapper: Mapper, action: CemuHookAction, data) -> None:
		"""Called by 'cemuhook' action"""
		if self.cemuhook is None:
			try:
				self.cemuhook = CemuhookServer(self)
			except Exception as e:
				log.error("Failed to initialize CemuHookUDP Motion Provider: %s", e)
				return
		self.cemuhook.feed(data)

	def _osd(self, *data) -> bool:
		"""Has to be called with self.lock held.

		Returns True on success.
		"""
		# Pre-format data
		data = b"OSD: %s\n" % (shjoin(data),)

		# Check if scc-osd-daemon is available
		if not self.osd_daemon:
			log.warning("Cannot show OSD; there is no scc-osd-daemon registered")
			return False
		# Send request
		try:
			self.osd_daemon.wfile.write(data)
			self.osd_daemon.wfile.flush()
		except Exception as e:
			log.error("Failed to display OSD: %s", e)
			self.osd_daemon = None
			return False
		return True

	def on_sa_osd(self, mapper: Mapper, action: OSDAction) -> None:
		"""Called when 'osd' action is used"""
		with self.lock:
			self._osd("message", "-t", str(action.timeout), "-s", str(action.size), action.text)

	def on_sa_clearosd(self, mapper: Mapper, action: ClearOSDAction) -> None:
		"""Called when 'clearosd' action is used"""
		with self.lock:
			self._osd("clear")

	def on_sa_area(self, mapper: Mapper, action: AreaAction, x1, y1, x2, y2) -> None:
		"""Called when *AreaAction has OSD enabled"""
		with self.lock:
			self._osd("area", "-x", str(x1), "-y", str(y1), "--width", str(x2 - x1), "--height", str(y2 - y1))

	def on_sa_clear_osd(self, *a) -> None:
		with self.lock:
			self._osd("clear")

	def on_sa_keyboard(self, mapper: Mapper, action: KeyboardAction) -> None:
		"""Called when 'keyboard' action is used"""
		with self.lock:
			self._osd("keyboard")

	def on_sa_menu(self, mapper: Mapper, action: MenuAction, *pars) -> None:
		"""Called when 'menu' action is used"""
		p = [action.MENU_TYPE]
		if mapper.get_controller():
			p += ["--controller", mapper.get_controller().get_id()]
		if "." in action.menu_id:
			path = find_menu(action.menu_id)
			if not path:
				log.error("Cannot show menu: Menu '%s' not found", action.menu_id)
				return
			p += ["--from-file", path]
		else:
			p += ["--from-profile", mapper.profile.get_filename(), action.menu_id]
		p += list(pars)

		with self.lock:
			self._osd(*p)

	on_sa_gridmenu = on_sa_menu

	def on_sa_dialog(self, mapper: Mapper, action: DialogAction, *pars) -> None:
		# Replace actions with id, title pairs
		data = []
		self.osd_ids = {}
		for x in pars:
			if isinstance(x, Action):
				id = str(hash(x))
				self.osd_ids[id] = x.strip()
				data += [id, x.describe(Action.AC_MENU)]
			else:
				data.append(x)

		with self.lock:
			self._osd("dialog", *data)

	def on_sa_profile(self, mapper: Mapper, action: ChangeProfileAction) -> None:
		"""Called when 'profile' action is used"""
		name = action.profile
		if "/" in name:
			# Small sanity check
			log.error("Cannot load profile: Profile '%s' not found", name)
			return
		path = find_profile(name)
		if path:
			with self.lock:
				try:
					self._set_profile(mapper, path)
					log.info("Loaded profile '%s'", name)
				except Exception as e:
					log.exception(e)
			return
		log.error("Cannot load profile: Profile '%s' not found", name)

	def on_start(self) -> None:
		os.chdir(self.cwd)

	# def on_controller_status(self, sc: SCController, onoff: bool) -> None:
	# 	if onoff:
	# 		log.debug("Controller turned ON")
	# 	else:
	# 		log.debug("Controller turned OFF")

	def sigterm(self, *a) -> Never:
		self.exiting = True
		for fn in self.on_exit_cbs:
			fn(self)
		for d in (self.osd_daemon, self.autoswitch_daemon):
			if d:
				d.wfile.close()
		self.osd_daemon, self.autoswitch_daemon = None, None
		for p in self.subprocs:
			p.kill()
		self.subprocs = []
		sys.exit(0)

	def connect_x(self) -> None:
		"""Creates connection to X Server"""
		if "WAYLAND_DISPLAY" in os.environ:
			import gi

			gi.require_version("Gtk", "3.0")
			try:
				gi.require_version("GtkLayerShell", "0.1")
				from gi.repository import GtkLayerShell

				if GtkLayerShell.is_supported():
					log.warning(
						"Wayland detected. Disabling X11 support, some functionality will be unavailable",
					)
					self.xdisplay = None
					self.subprocs.append(Subprocess("scc-osd-daemon", True))
					return
			except (ImportError, ValueError):
				pass

			log.warning(
				"Wayland without zwlr_layer_shell_v1 support detected! Disabling X11 support, some functionality, including OSD, will be unavailable"
			)
			self.xdisplay = None
			return

		if "DISPLAY" not in os.environ:
			log.error(
				"Neither DISPLAY nor WAYLAND_DISPLAY is set. Some functionality will be unavailable - are you launching SCC via an SSH session?",
			)
			self.xdisplay = None
			return

		self.xdisplay = X.open_display(os.environ["DISPLAY"].encode("utf-8"))
		if self.xdisplay:
			log.debug("Connected to XServer %s", os.environ["DISPLAY"])

			for c in self.controllers:
				if c.get_mapper():
					c.get_mapper().set_xdisplay(self.xdisplay)
			for m in self.free_mappers:
				m.set_xdisplay(self.xdisplay)
			if not self.alone:
				self.subprocs.append(Subprocess("scc-osd-daemon", True))
				if len(Config()["autoswitch"]):
					# Start scc-autoswitch-daemon only if there are some switch rules defined
					self.subprocs.append(Subprocess("scc-autoswitch-daemon", True))
		else:
			log.warning("Failed to connect to XServer. Some functionality will be unavailable")
			self.xdisplay = None

	def init_mapper(self) -> Mapper:
		"""Setups new mapper instance."""
		try:
			mapper = Mapper(Profile(TalkingActionParser()), self.scheduler, poller=self.poller)
		except CannotCreateUInputException as e:
			# Most likely UInput is not available
			# Create mapper with all virtual devices set to Dummies.
			log.exception(e)
			self.add_error("uinput", str(e))
			mapper = Mapper(Profile(TalkingActionParser()), self.scheduler, keyboard=None, mouse=None, gamepad=False)

		mapper.set_special_actions_handler(self)
		mapper.set_xdisplay(self.xdisplay)
		mapper.schedule(1.0, self.fix_xinput)
		return mapper

	def fix_xinput(self, mapper: Mapper) -> None:
		name = mapper.get_gamepad_name()
		if self.xdisplay and Config()["fix_xinput"] and name:
			# Three conditions: X has to be available, 'fix_xinput' must
			# be enabled in config and controller should not be dummy
			# (should have a name)
			try:
				for d in xinput.get_devices():
					if d.get_name() == name:
						if d.is_pointer() and d.is_slave():
							d.float()
			except OSError as e:
				# Most likely 'xinput' executable not found
				log.warning("Failed to deatach gamepad from xinput master: %s", e)

	def load_default_profile(self, mapper: Mapper | None = None) -> None:
		mapper = mapper or self.default_mapper
		if self.default_profile is None:
			try:
				self.default_profile = find_profile(Config()["recent_profiles"][0])
			except Exception:
				# Broken config is not reason to fail here
				pass
		try:
			mapper.profile.load(self.default_profile).compress()
		except Exception as e:
			log.warning("Failed to load profile. Starting with no mappings.")
			log.warning("Reason: %s", e)

	def add_controller(self, c: Controller) -> None:
		if len(self.free_mappers) > 0:
			# Reuse already created mapper, so SCC will not spam system
			# with fake devices
			mapper, self.free_mappers = self.free_mappers[0], self.free_mappers[1:]
			if mapper != self.default_mapper:
				log.debug("Reused mapper %s for %s", mapper, c)
		else:
			# New controller, but no mapper created
			mapper = self.init_mapper()
			self.load_default_profile(mapper)
		mapper.set_controller(c)
		c.set_mapper(mapper)
		if mapper == self.default_mapper:
			log.debug("Assigned default_mapper to %s", c)
		if mapper.profile.gyro:
			log.debug("Turning gyrosensor ON")
			c.set_gyro_enabled(True)

		c.apply_config(Config().get_controller_config(c.get_id()))
		self.controllers.append(c)
		log.debug("Controller added: %s", c)
		with self.lock:
			self.send_controller_list(self._send_to_all)
			self.send_all_profiles(self._send_to_all)

	def remove_controller(self, c: Controller) -> None:
		mapper = c.mapper
		if mapper:
			mapper.clear_bt_stick_mouse_velocity()
			mapper.release_virtual_buttons()
		c.disconnected()

		with self.lock:
			while c in self.controllers:
				self.controllers.remove(c)
			log.debug("Controller removed: %s", c)

			if mapper == self.default_mapper and len(self.controllers) > 0:
				# Special case, default_mapper should be always
				# assigned to something, so if controller with default_mapper
				# is disconnected, it's reassigned to next available controller
				swap_c = self.controllers[0]
				swap_mapper = swap_c.get_mapper()
				swap_mapper.set_controller(None)
				swap_c.set_mapper(mapper)
				mapper.set_controller(swap_c)
				self.free_mappers.append(swap_mapper)
				log.debug("Reassigned default_mapper to %s", swap_c)
			else:
				c.set_mapper(None)
				if mapper:
					mapper.set_controller(None)
					self.free_mappers.append(mapper)
			self.send_controller_list(self._send_to_all)

	def get_active_ids(self):
		"""Returns iterable with IDs of all active controllers"""
		return [x.get_id() for x in self.controllers]

	def add_error(self, id, error: str) -> None:
		"""Adds error (string) to report. Used when USB driver reports that device cannot be accessed or when UInput is not available.

		Every error has id that can be later used to remove it from list to
		indicate that error has been resolved.
		"""
		with self.lock:
			self.errors.append((id, error))
			self._send_to_all((f"Error: {error}\n").encode())

	def remove_error(self, id) -> None:
		"""Removes error added with 'add_error'. If such error cannot be found, does nothing.

		When last error is removed, this method automatically sends "Ready."
		message to indicate that daemon is ready to serve clients.
		"""
		with self.lock:
			self.errors = [(_id, error) for (_id, error) in self.errors if _id != id]
			if len(self.errors) == 0:
				self._send_to_all(b"Ready.\n")

	def send_controller_list(self, method) -> None:
		"""Sends controller count and list of controllers using provided method"""
		for c in self.controllers:
			method(
				(f"Controller: {c.get_id()} {c.get_type()} {c.flags} {c.get_gui_config_file()}\n").encode(),
			)
		method((f"Controller Count: {len(self.controllers)}\n").encode())

	def send_profile_info(self, controller: Controller, method, mapper: Mapper | None = None) -> bool:
		"""Sends info about current profile using provided method.

		Returns True if mapper is default_mapper.
		"""
		mapper = mapper or controller.mapper
		if controller:
			method(
				(f"Controller profile: {controller.get_id()} {mapper.profile.get_filename()}\n").encode(),
			)
		if mapper == self.default_mapper:
			method((f"Current profile: {mapper.profile.get_filename()}\n").encode())
			return True
		return False

	def send_all_profiles(self, method) -> None:
		"""Sends info about all profiles assigned to all controllers using provided method."""
		# As special case, at least default_mapper profile has to be sent always
		default_sent = False
		for c in self.controllers:
			default_sent = self.send_profile_info(c, method) or default_sent
		if not default_sent:
			self.send_profile_info(None, method, mapper=self.default_mapper)

	def run(self) -> Never:
		log.debug("Starting SCCDaemon...")
		signal.signal(signal.SIGTERM, self.sigterm)
		self.init_sleep_monitor()
		self.init_drivers()
		self.dev_monitor.start()
		load_custom_module(log)
		self.default_mapper = self.init_default_mapper()
		self.free_mappers.append(self.default_mapper)
		self.load_default_profile()
		self.lock.acquire()
		self.start_listening()
		self.connect_x()
		self.lock.release()
		self.start_drivers()
		self.dev_monitor.rescan()

		while True:
			for fn in self.mainloops:
				fn()

	def start_listening(self) -> None:
		if os.path.exists(self.socket_file):
			os.unlink(self.socket_file)
		instance = self

		class SSHandler(StreamRequestHandler):
			def handle(self):
				instance._sshandler(self.connection, self.rfile, self.wfile)

		self.sserver = ThreadingUnixStreamServer(self.socket_file, SSHandler)
		t = threading.Thread(target=self.sserver.serve_forever)
		t.daemon = True
		t.start()
		os.chmod(self.socket_file, stat.S_IRUSR | stat.S_IWUSR)
		log.debug("Created control socket %s", self.socket_file)

	def _start_gesture(
		self, mapper: Mapper, what: bytes, up_angle: int, callback: Callable[[str], None],
	) -> GestureDetector:
		"""Starts gesture detection on specified pad.

		Calls callback with gesture string when finished.

		Should be called with lock held.
		"""
		gd: GestureDetector | None = None

		def cb(detector: GestureDetector, gesture) -> None:
			# This callback is expected to be called with lock held
			with self.lock:
				self._apply(mapper, what, lambda a: a.original_action)
			log.debug("Gesture detected on %s: %s", what, gesture)
			callback(gesture)

		def set(action: Action) -> ObservingAction | GestureDetector:
			# ObservingAction should be above GestureDetector
			if isinstance(action, ObservingAction):
				gd.original_action = action.original_action
				action.original_action = gd
				return action
			gd.original_action = action
			return gd

		gd = GestureDetector(up_angle, cb)
		self._apply(mapper, what, set)
		return gd

	def _sshandler(self, connection, rfile, wfile) -> None:
		with self.lock:
			client = Client(connection, self.default_mapper, rfile, wfile)
			self.clients.add(client)
			wfile.write(b"SCCDaemon\n")
			wfile.write((f"Version: {DAEMON_VERSION}\n").encode())
			wfile.write((f"PID: {os.getpid()}\n").encode())
			self.send_controller_list(wfile.write)
			self.send_all_profiles(wfile.write)
			if len(self.errors) == 0:
				wfile.write(b"Ready.\n")
			else:
				for id, error in self.errors:
					wfile.write((f"Error: {error}\n").encode())

		while True:
			try:
				line = rfile.readline()
			except Exception:
				# Connection terminated
				break
			if len(line) == 0:
				break
			if len(line.strip(b"\t\n ")) > 0:
				self._handle_message(client, line.strip(b"\n"))

		with self.lock:
			client.unlock_actions(self)
			if self.osd_daemon == client:
				log.info("scc-osd-daemon lost")
				self.osd_daemon = None
			if self.autoswitch_daemon == client:
				log.info("scc-autoswitch-daemon lost")
				self.autoswitch_daemon = None
			self.clients.remove(client)

	def _handle_message(self, client: Client, message: bytes) -> None:
		"""Handles message received from client."""
		if message.startswith(b"Profile:"):
			with self.lock:
				try:
					filename = message[8:].decode("utf-8").strip("\t ")
					self._set_profile(client.mapper, filename)
					log.info("Loaded profile '%s'", filename)
					client.wfile.write(b"OK.\n")
				except Exception as e:
					exc = traceback.format_exc()
					log.exception(e)
					tb = str(exc).encode("utf-8").decode("unicode_escape").encode("latin1")
					client.wfile.write(b"Fail: " + tb + b"\n")
		elif message.startswith(b"OSD:"):
			if not self.osd_daemon:
				client.wfile.write(b"Fail: Cannot show OSD; there is no scc-osd-daemon registered\n")
			else:
				try:
					text = message[5:].decode("utf-8").strip("\t ")
					with self.lock:
						if not self._osd("message", text):
							raise Exception
					client.wfile.write(b"OK.\n")
				except Exception:
					client.wfile.write(b"Fail: cannot display OSD\n")
		elif message.startswith(b"Feedback:"):
			try:
				position, amplitude = message[9:].strip().split(b" ", 2)
				data = HapticData(getattr(HapticPos, position.decode("ascii")), int(amplitude))
				if client.mapper.get_controller():
					client.mapper.get_controller().feedback(data)
				client.wfile.write(b"OK.\n")
			except Exception as e:
				log.exception(e)
				client.wfile.write((f"Fail: {e}\n").encode())
		elif message.startswith(b"Controller."):
			with self.lock:
				client.mapper = self.default_mapper
				client.wfile.write(b"OK.\n")
		elif message.startswith(b"Controller:"):
			with self.lock:
				try:
					controller_id = message[11:].strip().decode("utf-8")
					for c in self.controllers:
						if c.get_id() == controller_id:
							client.mapper = c.get_mapper()
							client.wfile.write(b"OK.\n")
							break
					else:
						raise Exception("goto fail")
				except Exception:
					client.wfile.write(b"Fail: no such controller\n")
		elif message.startswith(b"State."):
			if Config()["enable_sniffing"]:
				client.wfile.write(b"State: %s\n" % (str(client.mapper.state),))
			else:
				log.warning("Refused 'State' request: Sniffing disabled")
				client.wfile.write(b"Fail: Sniffing disabled.\n")
		elif message.startswith(b"Led:"):
			try:
				number = int(message[4:])
				number = clamp(0, number, 100)
			except Exception as e:
				client.wfile.write(f"Fail: {e}\n".encode())
				return
			if client.mapper.get_controller():
				client.mapper.get_controller().set_led_level(number)
		elif message.startswith(b"Observe:"):
			if Config()["enable_sniffing"]:
				to_observe = list(message.split(b":", 1)[1].strip(b" \t\r").split(b" "))
				with self.lock:
					for source in to_observe:
						client.observe_action(self, SCCDaemon.source_to_constant(s=source))
					client.wfile.write(b"OK.\n")
			else:
				log.warning("Refused 'Observe' request: Sniffing disabled")
				client.wfile.write(b"Fail: Sniffing disabled.\n")
		elif message.startswith(b"Replace:"):
			try:
				source, actionstr = message.split(b":", 1)[1].strip(b" \t\r").split(b" ", 1)
				action = TalkingActionParser().restart(actionstr.decode()).parse().compress()
			except Exception as e:
				e = str(e).encode("utf-8").decode("unicode_escape").encode("latin1")
				client.wfile.write(b"Fail: failed to parse: " + e + b"\n")
				return
			with self.lock:
				try:
					if not self._can_lock_action(client.mapper, SCCDaemon.source_to_constant(source)):
						client.wfile.write(b"Fail: Cannot lock " + source + b"\n")
						return
				except ValueError:
					tb = str(traceback.format_exc()).encode("utf-8").decode("unicode_escape").encode("latin1")
					client.wfile.write(b"Fail: " + tb + b"\n")
					return
				client.replace_action(self, SCCDaemon.source_to_constant(source), action)
				client.wfile.write(b"OK.\n")
		elif message.startswith(b"Lock:"):
			to_lock = list(message.split(b":", 1)[1].strip(b" \t\r").split(b" "))
			with self.lock:
				try:
					for source in to_lock:
						if not self._can_lock_action(client.mapper, SCCDaemon.source_to_constant(source)):
							client.wfile.write(b"Fail: Cannot lock " + source + b"\n")
							return
				except ValueError:
					tb = str(traceback.format_exc()).encode("utf-8").decode("unicode_escape").encode("latin1")
					client.wfile.write(b"Fail: " + tb + b"\n")
					return
				for source in to_lock:
					client.lock_action(self, SCCDaemon.source_to_constant(source))
				client.wfile.write(b"OK.\n")
		elif message.startswith(b"Unlock."):
			with self.lock:
				client.unlock_actions(self)
				client.wfile.write(b"OK.\n")
		elif message.startswith(b"Reconfigure."):
			with self.lock:
				# Load config
				cfg = Config()
				# Reconfigure connected controllers
				for c in self.controllers:
					c.apply_config(cfg.get_controller_config(c.get_id()))
				# Start or stop scc-autoswitch-daemon as needed
				need_autoswitch_daemon = len(cfg["autoswitch"]) > 0
				if need_autoswitch_daemon and self.xdisplay and not self.autoswitch_daemon:
					self.subprocs.append(Subprocess("scc-autoswitch-daemon", True))
				elif not need_autoswitch_daemon and self.autoswitch_daemon:
					self._remove_subproccess("scc-autoswitch-daemon")
					self.autoswitch_daemon.close()
					self.autoswitch_daemon = None
				# Respond
				try:
					client.wfile.write(b"OK.\n")
					self._send_to_all(b"Reconfigured.\n")
				except Exception:
					pass
		elif message.startswith(b"Rescan."):
			cbs = []
			with self.lock:
				cbs += self.rescan_cbs
				# Respond first
				try:
					client.wfile.write(b"OK.\n")
				except Exception:
					pass
			# Do stuff later
			# (this cannot be done while self.lock is held, as creating new
			# controller would create race condition)
			for cb in self.rescan_cbs:
				try:
					cb()
				except Exception as e:
					log.exception(e)
			# dev_monitor rescan has to be last to run
			try:
				self.dev_monitor.rescan()
			except Exception as e:
				log.exception(e)
		elif message.startswith(b"Turnoff."):
			to_turn_off = []
			with self.lock:
				if client.mapper.get_controller():
					to_turn_off.append(client.mapper.get_controller())
				else:
					to_turn_off += [c for c in self.controllers]
			for c in to_turn_off:
				c.turnoff()
			client.wfile.write(b"OK.\n")
		elif message.startswith(b"Gesture:"):
			try:
				what, up_angle = message[8:].strip().split(b" ", 2)
				up_angle = int(up_angle)
			except Exception:
				tb = str(traceback.format_exc()).encode("utf-8").decode("unicode_escape").encode("latin1")
				client.wfile.write(b"Fail: " + tb + b"\n")
				return
			with self.lock:
				client.request_gesture(self, what, up_angle)
				client.wfile.write(b"OK.\n")
		elif message.startswith(b"Restart."):
			self.on_sa_restart()
		elif message.startswith(b"Gestured:"):
			gstr = message[9:].strip()
			client.gesture_action.gesture(client.mapper, gstr)
			with self.lock:
				client.wfile.write(b"OK.\n")
		elif message.startswith(b"Selected:"):
			menuaction = None

			def press(mapper):
				try:
					menuaction.button_press(mapper)
					client.mapper.schedule(0.1, release)
				except Exception as e:
					log.error("Error while processing menu action")
					log.exception(e)

			def release(mapper):
				try:
					menuaction.button_release(mapper)
				except Exception as e:
					log.error("Error while processing menu action")
					log.exception(e)

			with self.lock:
				try:
					# menu_id, item_id = shsplit(message)[1:]
					# print(message)
					tmp = shsplit(str(message, "utf-8"))
					menu_id, item_id = tmp[1:]
					menuaction = None
					if menu_id in (None, "None"):
						menuaction = self.osd_ids[item_id]
					elif "." in menu_id:
						# TODO: Move this common place
						with open(menu_id) as file:
							data = json.loads(file.read())
						menudata = MenuData.from_json_data(data, TalkingActionParser())
						menuaction = menudata.get_by_id(item_id).action
					else:
						menuaction = client.mapper.profile.menus[menu_id].get_by_id(item_id).action
					client.wfile.write(b"OK.\n")
				except Exception:
					log.warning("Selected menu item is no longer valid.")
					client.wfile.write(b"Fail: Selected menu item is no longer valid\n")
				if menuaction:
					client.mapper.schedule(0, press)
		elif message.startswith(b"Register:"):
			with self.lock:
				if message.strip().endswith(b"osd"):
					if self.osd_daemon:
						self.osd_daemon.close()
					self.osd_daemon = client
					log.info("Registered scc-osd-daemon")
				elif message.strip().endswith(b"autoswitch"):
					if self.autoswitch_daemon:
						self.autoswitch_daemon.close()
					self.autoswitch_daemon = client
					log.info("Registered scc-autoswitch-daemon")
				client.wfile.write(b"OK.\n")
		else:
			client.wfile.write(b"Fail: Unknown command\n")

	def _remove_subproccess(self, binary_name) -> None:
		"""Removes subproccess started with specified binary name from list of managed subproccesses,

		effectively preventing daemon from auto-restarting it.

		Should be called while lock is acquired
		"""
		n = []
		for i in self.subprocs:
			if i.binary_name == binary_name:
				i.mark_killed()
			else:
				n.append(i)
		self.subprocs = n

	def _can_lock_action(self, mapper: Mapper, what) -> bool:
		"""Returns True if action assigned to axis, pad or button is not yet locked.

		Should be called while self.lock is acquired.
		"""
		# TODO: Probably move to mapper
		is_locked = lambda a: (
			isinstance(a, LockedAction)
			or (isinstance(a, ObservingAction) and isinstance(a.original_action, LockedAction))
		)

		if what == SCSticks.LSTICK:
			if is_locked(mapper.profile.buttons[SCButtons.LSTICKPRESS]):
				return False
			if is_locked(mapper.profile.lstick):
				return False
			return True
		if what == SCSticks.RSTICK:
			if is_locked(mapper.profile.buttons[SCButtons.RSTICKPRESS]):
				return False
			if is_locked(mapper.profile.rstick):
				return False
			return True
		if what == SCButtons.LT:
			return not is_locked(mapper.profile.triggers[SCTriggers.LT])
		if what == SCButtons.RT:
			return not is_locked(mapper.profile.triggers[SCTriggers.RT])
		if what in SCButtons.__members__.values():
			return not is_locked(mapper.profile.buttons[what])
		if what in (SCPads.LPAD, SCPads.RPAD, SCPads.CPAD, SCPads.DPAD):
			return not is_locked(mapper.profile.pads[what])
		return False

	def _apply(
		self,
		mapper: Mapper,
		what: SCButtons | SCSticks | SCPads,
		callback: Callable[[Action], ObservingAction | GestureDetector],
		*args,
	) -> None:
		"""Applies callback on action that is currently set to input specified by 'what'.

		Raises ValueError if what is not known.

		For example, if what == LSTICK, executes
			mapper.profile.lstick = callback(mapper.profile.lstick, *args)
		"""
		if what == SCSticks.LSTICK:
			mapper.profile.lstick = callback(mapper.profile.lstick, *args)
		elif what == SCSticks.RSTICK:
			mapper.profile.rstick = callback(mapper.profile.rstick, *args)
		elif what == SCButtons.LT:
			mapper.profile.triggers[SCTriggers.LT] = callback(mapper.profile.triggers[SCTriggers.LT], *args)
		elif what == SCButtons.RT:
			mapper.profile.triggers[SCTriggers.RT] = callback(mapper.profile.triggers[SCTriggers.RT], *args)
		elif what in SCButtons.__members__.values():
			r = callback(mapper.profile.buttons[what], *args)
			mapper.profile.buttons[what] = r
		elif what in (SCPads.LPAD, SCPads.RPAD):
			if what == SCPads.LPAD:
				mapper.buttons &= ~SCButtons.LPADTOUCH
			else:
				mapper.buttons &= ~SCButtons.RPADTOUCH
			a = callback(mapper.profile.pads[what], *args)
			a.whole(mapper, 0, 0, what)
			mapper.profile.pads[what] = a
		elif what in (CPAD, DPAD):
			a = callback(mapper.profile.pads[what], *args)
			a.whole(mapper, 0, 0, what)
			mapper.profile.pads[what] = a
		else:
			raise ValueError(f"Unknown source: {what}")

	@staticmethod
	def source_to_constant(s: bytes) -> SCButtons | SCSticks | SCPads:
		"""Turns byte string as 'A', 'LEFT' or 'ABS_X' into one of:

		SCButtons.*, SCSticks.*, SCPads.* constants.

		Raises ValueError if passed string cannot be converted.

		Used when parsing `Lock: ...` message
		"""
		s_dec = s.decode("utf-8").strip(" \t\r\n")
		if s_dec == "LPADPRESS":
			return SCButtons.LPAD
		if s_dec == "RPADPRESS":
			return SCButtons.RPAD
		# Protocol compatibility for clients predating the LSTICK rename.
		if s_dec == "STICK":
			log.warning("STICK detected, please migrate to LSTICK")
			s_dec = SCSticks.LSTICK
		elif s_dec == "STICKPRESS":
			log.warning("STICKPRESS detected, please migrate to LSTICKPRESS")
			s_dec = "LSTICKPRESS"
		if s_dec in (SCSticks.LSTICK, SCSticks.RSTICK, SCPads.LPAD, SCPads.RPAD, SCPads.CPAD, SCPads.DPAD):
			return s_dec
		if hasattr(SCButtons, s_dec):
			return getattr(SCButtons, s_dec)
		raise ValueError(f"Unknown source: {s_dec}")

	def _remove_socket(self) -> None:
		self.sserver.shutdown()
		if os.path.exists(self.socket_file):
			os.unlink(self.socket_file)
		log.debug("Control socket removed")

	def debug(self) -> Never:
		set_logging_level(True, True)
		self.on_start()
		self.write_pid()
		try:
			self.run()
		except KeyboardInterrupt:
			log.debug("Break")
		self.sigterm()


class Client:
	def __init__(self, connection, mapper: Mapper, rfile, wfile) -> None:
		self.connection = connection
		self.rfile = rfile
		self.wfile = wfile
		self.mapper: Mapper = mapper
		self.gesture_action = None
		self.locked_actions = {}

	def close(self) -> None:
		"""Closes connection to this client"""
		try:
			self.connection.shutdown(True)
		except Exception:
			pass

	def request_gesture(self, daemon: SCCDaemon, what: bytes, up_angle: int) -> None:
		"""Handler used when client requested gesture detection with "Gesture:" message.

		Should be called while daemon.lock is acquired.
		"""

		def cb(gesture) -> None:
			# Called while lock is being held
			try:
				self.wfile.write(b"Gesture: %s %s\n" % (what, gesture))
			except Exception:
				pass

		gd = daemon._start_gesture(self.mapper, what, up_angle, cb)
		gd.enable()
		log.debug("Gesture detection requested on %s", what)

	def lock_action(self, daemon: SCCDaemon, what: SCButtons | SCSticks | SCPads) -> None:
		"""Locks action so event can be send to client instead of handling it.

		Should be called while daemon.lock is acquired.
		"""

		def lock(action: Action, what: str) -> ObservingAction | LockedAction:
			# ObservingAction should be above LockedAction
			if isinstance(action, ObservingAction):
				action.original_action = LockedAction(what, self, action.original_action)
				return action
			return LockedAction(what, self, action)

		daemon._apply(self.mapper, what, lock, what)

	def observe_action(self, daemon: SCCDaemon, what) -> None:
		"""Enables observing of action so event is both sent to client and handled.

		Should be called while daemon.lock is acquired.
		"""
		daemon._apply(self.mapper, what, lambda a: ObservingAction(what, self, a))

	def replace_action(self, daemon: SCCDaemon, what, action) -> None:
		"""Temporally replaces action in way that allows reversing operation when client disconnects.

		Should be called while daemon.lock is acquired.
		"""
		daemon._apply(self.mapper, what, lambda a: ReplacedAction(what, self, action, a))

	def unlock_actions(self, daemon: SCCDaemon) -> None:
		"""Should be called while daemon.lock is acquired"""
		locked, self.locked_actions = self.locked_actions, {}
		for mapper in locked:
			s = locked[mapper]
			for a in s:
				a.unlock(daemon)

	def reaply_locks(self, daemon: SCCDaemon, mapper: Mapper) -> None:
		"""Called after profile is changed.

		Should be called while daemon.lock is acquired
		"""
		if mapper in self.locked_actions:
			s, self.locked_actions[mapper] = self.locked_actions[mapper], set()
			for a in s:
				a.reaply(self, daemon)


class ReportingAction(Action):
	"""Action used to send requested inputs to client.

	Base for LockedAction and ObservingAction
	"""

	MIN_DIFFERENCE = 300

	def __init__(self, what, client: Client) -> None:
		self.what = what
		self.client: Client = client
		self.mapper: Mapper = client.mapper
		self.old_pos = 0, 0

	def _store_lock(self) -> None:
		if self.mapper not in self.client.locked_actions:
			self.client.locked_actions[self.mapper] = set()
		self.client.locked_actions[self.mapper].add(self)

	def __repr__(self) -> str:
		return f"<{self.__class__.__name__} of {hash(self.client):x}>"

	__str__ = __repr__

	def _report(self, message: str) -> None:
		try:
			self.client.wfile.write(message.encode("utf-8"))
		except Exception:
			# May fail when client dies
			self.client.rfile.close()
			self.client.wfile.close()

	def trigger(self, mapper: Mapper, position, old_position) -> None:
		if mapper.get_controller():
			self._report(
				f"Event: {mapper.get_controller().get_id()} {nameof(self.what)} {position} {old_position}\n",
			)

	def button_press(self, mapper: Mapper, number: int = 1) -> None:
		if mapper.get_controller():
			protocol_name = {
				SCButtons.LPAD: "LPADPRESS",
				SCButtons.RPAD: "RPADPRESS",
			}.get(self.what, nameof(self.what))
			if protocol_name:
				self._report(f"Event: {mapper.get_controller().get_id()} {protocol_name} {number}\n")
			else:
				log.warning("Cannot report unnamed button %r", self.what)

	def button_release(self, mapper: Mapper) -> None:
		ReportingAction.button_press(self, mapper, 0)

	def whole(self, mapper: Mapper, x, y, what) -> None:
		min_difference = self.MIN_DIFFERENCE
		if what == CPAD:
			min_difference /= 10
		if x == 0 or y == 0 or abs(x - self.old_pos[0]) > min_difference or abs(y - self.old_pos[1]) > min_difference:
			self.old_pos = x, y
			if mapper.get_controller():
				self._report(f"Event: {mapper.get_controller().get_id()} {what} {x} {y}\n")


class LockedAction(ReportingAction):
	"""Temporal action used to send requested inputs to client"""

	def __init__(self, what, client: Client, original_action) -> None:
		ReportingAction.__init__(self, what, client)
		self.original_action = original_action
		original_action.cancel(self.mapper)
		self._store_lock()
		log.debug("%s locked by %s", nameof(self.what), self.client)

	def reaply(self, client: Client, daemon: SCCDaemon) -> None:
		client.lock_action(daemon, self.what)

	def unlock(self, daemon: SCCDaemon) -> None:
		def _unlock(a: Action) -> Action:
			if isinstance(a, ObservingAction):
				# Needs to be handled specifically
				a.original_action = _unlock(a.original_action)
				return a
			if isinstance(a, LockedAction):
				return a.original_action
			return a

		daemon._apply(self.mapper, self.what, _unlock)
		log.debug("%s unlocked", nameof(self.what))


class ReplacedAction(LockedAction):
	def __init__(self, what, client: Client, new_action, original_action) -> None:
		ReportingAction.__init__(self, what, client)
		self.original_action = original_action
		self.new_action = new_action.compress()
		original_action.cancel(self.mapper)
		self._store_lock()
		log.debug("%s replaced by %s", nameof(self.what), self.client)

	def reaply(self, client: Client, daemon: SCCDaemon) -> None:
		client.replace_action(daemon, self.what, self.new_action)

	def trigger(self, mapper: Mapper, position, old_position) -> None:
		self.new_action.trigger(mapper, position, old_position)

	def button_press(self, mapper: Mapper, number=1) -> None:
		self.new_action.button_press(mapper, mapper)

	def button_release(self, mapper: Mapper) -> None:
		self.new_action.button_release(mapper, mapper)

	def whole(self, mapper: Mapper, x, y, what) -> None:
		self.new_action.whole(mapper, x, y, what)


class ObservingAction(ReportingAction):
	"""Similar to LockedAction, send inputs to client *and* executes actions."""

	def __init__(self, what, client: Client, original_action) -> None:
		ReportingAction.__init__(self, what, client)
		self.original_action = original_action
		self._store_lock()
		log.debug("%s on %s observed by %x", nameof(self.what), client.mapper.get_controller(), hash(self.client))

	def reaply(self, client, daemon: SCCDaemon) -> None:
		client.observe_action(daemon, self.what)

	def cancel(self, mapper: Mapper) -> None:
		self.original_action.cancel(mapper)

	def unlock(self, daemon: SCCDaemon) -> None:
		def _unobserve(a: LockedAction | ObservingAction | ReplacedAction):
			if isinstance(a, ObservingAction):
				if a.client == self.client:
					return a.original_action
				a.original_action = _unobserve(a.original_action)
				return a
			if isinstance(a, LockedAction):
				a.original_action = _unobserve(a.original_action)
				return a
			return a

		daemon._apply(self.mapper, self.what, _unobserve)
		log.debug(
			"%s on %s no longer observed by %x", nameof(self.what), self.mapper.get_controller(), hash(self.client)
		)

	def trigger(self, mapper: Mapper, position, old_position) -> None:
		ReportingAction.trigger(self, mapper, position, old_position)
		self.original_action.trigger(mapper, position, old_position)

	def button_press(self, mapper: Mapper, number: int = 1) -> None:
		ReportingAction.button_press(self, mapper, number)
		self.original_action.button_press(mapper)

	def button_release(self, mapper: Mapper) -> None:
		ReportingAction.button_release(self, mapper)
		self.original_action.button_release(mapper)

	def whole(self, mapper: Mapper, x, y, what) -> None:
		ReportingAction.whole(self, mapper, x, y, what)
		self.original_action.whole(mapper, x, y, what)


class Subprocess:
	"""Part of scc-daemon executed as another process, killed along with scc-daemon.

	Currently scc-osd-daemon and scc-windowswitch-daemon.
	"""

	def __init__(self, binary_name: str, debug: bool, restart_after: int = 5) -> None:
		self.binary_name: str = binary_name
		self.restart_after: int = restart_after
		self.args: list[str] = [sys.executable, find_binary(binary_name)]
		if debug:
			self.args.append("debug")
		self._killed: bool = False
		self.p = None
		self.t: Thread = threading.Thread(target=self._threaded)
		self.t.daemon = True
		self.t.start()

	def _threaded(self, *a) -> None:
		while not self._killed:
			self.p = subprocess.Popen(self.args, stdin=None)
			self.p.communicate()
			if self.p and self.p.returncode == 8:
				log.warning("%s exited with code 8; not restarting", self.binary_name)
				self.p = None
				return
			self.p = None
			if not self._killed:
				log.warning("%s died; restarting after %ss", self.binary_name, self.restart_after)
				time.sleep(self.restart_after)

	def mark_killed(self) -> None:
		"""Prevents subprocess from being automatically restarted, but doesn't really kill it."""
		self._killed = True

	def kill(self) -> None:
		self.mark_killed()
		if self.p:
			self.p.kill()
		self.p = None
