"""Classes and functions that create an easy way to create a Windows service in python."""

from __future__ import annotations

import contextlib
import inspect
import json
import logging
import logging.config
import os
import sys
import time
from abc import ABC
from abc import abstractmethod
from argparse import REMAINDER
from argparse import ArgumentError
from argparse import ArgumentParser
from argparse import Namespace
from concurrent.futures import CancelledError
from concurrent.futures import ThreadPoolExecutor
from dataclasses import MISSING
from dataclasses import Field
from dataclasses import dataclass
from dataclasses import field
from dataclasses import fields
from datetime import datetime
from datetime import timedelta
from pathlib import Path
from threading import Event
from threading import Thread
from threading import Timer
from typing import TYPE_CHECKING
from typing import Final
from typing import NoReturn
from typing import Protocol
from typing import Self

from dateutil.relativedelta import relativedelta

if TYPE_CHECKING:  # pragma: no cover
    from argparse import _ArgumentGroup
    from argparse import _SubParsersAction
    from collections.abc import Callable
    from collections.abc import Generator
    from collections.abc import Mapping
    from collections.abc import Sequence
    from concurrent.futures import Executor
    from logging import Logger
    from typing import Any

logger: Logger = logging.getLogger(__name__)

type CommandResult = int | str

SUCCESS: Final[CommandResult] = 0
ARGUMENT_ERROR: Final[CommandResult] = "ARGUMENT_ERROR"
ARGPARSE_EXIT: Final[CommandResult] = "ARGPARSE_EXIT"
FAILURE: Final[CommandResult] = -1

COMMAND_PREFIX: Final[str] = "command_"

WindowsService: type | None = None


@dataclass(frozen=True)
class Command:
    """The parameters required create a *SubParser* with arguments."""

    args: Sequence[Argument]
    """Sequence of arguments used for this command."""
    kwargs: Mapping[str, Any]
    """Keyword arguments passed to the *add_parser* function."""

    def __init__(self, *args: Argument, **kwargs: Any) -> None:
        """Create a new CommandDefinition instance."""
        object.__setattr__(self, "args", args)
        object.__setattr__(self, "kwargs", kwargs)


@dataclass(frozen=True)
class Argument:
    """The definition of a command argument."""

    args: Sequence[Any]
    """Arguments passed to *add_argument*."""
    kwargs: Mapping[str, Any]
    """Keyword arguments passed to *add_argument*."""

    def __init__(self, name_or_flag: str, *args: str, **kwargs: Any) -> None:
        """Create a new ArgumentDefinition instance."""
        object.__setattr__(self, "args", (name_or_flag, *args))
        object.__setattr__(self, "kwargs", kwargs)


class InvalidCommandNameError(NameError):
    """Exception caused when a service command does not have the correct prefix."""

    def __init__(self, command_func: ServiceCommand) -> None:
        """Create a new InvalidCommandNameError instance."""
        super().__init__(f"Invalid prefix: '{command_func.__name__}'")


class ServiceCommand(Protocol):
    """Represents the structure of a command."""

    __name__: str
    command: Command

    def __call__(self, *args: Any, **kwargs: Any) -> CommandResult:
        """Execute the command."""

    @staticmethod
    def create[T](*args: Any, **kwargs: Any) -> Callable[[T], T]:
        """Create a decorator to convert a *Service* class method into a command."""
        command: Command = Command(*args, **kwargs)

        def decorator[C: ServiceCommand](command_func: C) -> C:
            if not command_func.__name__.startswith(COMMAND_PREFIX):
                raise InvalidCommandNameError(command_func)
            command_func.command = command
            return command_func

        return decorator


@dataclass(frozen=True, kw_only=True)
class ServiceConfig:
    """Handles loading and saving the config file for a service."""

    @staticmethod
    def path_funcs() -> Mapping[str, Any]:
        """Return functions to save and load Path objects in config files."""

        def on_save(obj: Path | None) -> str | None:
            return None if obj is None else str(obj)

        def on_load(obj: str | None) -> Path | None:
            return None if obj is None else Path(obj)

        return {"on_save": on_save, "on_load": on_load}

    @staticmethod
    def datetime_funcs() -> Mapping[str, Any]:
        """Return functions to save and load datetime objects in config files."""

        def on_save(obj: datetime) -> str:
            return obj.isoformat()

        def on_load(obj: str) -> datetime:
            return datetime.fromisoformat(obj)

        return {"on_save": on_save, "on_load": on_load}

    @staticmethod
    def relativedelta_funcs() -> Mapping[str, Any]:
        """Return functions to save and load relativedelta objects in config files."""

        def on_save(obj: relativedelta) -> Mapping[str, Any]:
            data: Mapping[str, int] = {
                k: v
                for k, v in {
                    "years": obj.years,
                    "months": obj.months,
                    "days": obj.days,
                    "leapdays": obj.leapdays,
                    "hours": obj.hours,
                    "minutes": obj.minutes,
                    "seconds": obj.seconds,
                    "microseconds": obj.microseconds,
                }.items()
                if v != 0
            }
            return {"seconds": 0} if len(data) == 0 else data

        def on_load(obj: Mapping[str, Any]) -> relativedelta:
            return relativedelta(**obj)

        return {"on_save": on_save, "on_load": on_load}

    log_config: Mapping[str, Any] = field(
        default_factory=lambda: {
            "version": 1,
            "incremental": False,
            "disable_existing_loggers": False,
            "formatters": {"standard": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"}},
            "handlers": {
                "console": {
                    "class": "logging.StreamHandler",
                    "formatter": "standard",
                    "level": "INFO",
                    "stream": "ext://sys.stdout",
                },
                "file": {
                    "class": "logging.handlers.TimedRotatingFileHandler",
                    "formatter": "standard",
                    "filename": "Service.log",
                    "level": "DEBUG",
                    "when": "midnight",
                    "backupCount": 7,
                },
            },
            "root": {"level": "DEBUG", "handlers": ["console", "file"]},
        },
        metadata={
            "description": (
                "The values used to configure the application loggers. The dictionary must follow the standard logging"
                "dictionary schema (https://docs.python.org/3/library/logging.config.html#logging-config-dictschema)."
            ),
        },
    )

    frequency: relativedelta = field(
        default=relativedelta(seconds=-1),
        metadata={
            "description": (
                "The time to wait before running the next iteration after completing an iteration. "
                "If the total time is negative, then the service will only complete one iteration. "
                "Keys here will be passed to the relativedelta constructor."
            ),
            **relativedelta_funcs(),
        },
    )

    def save_to_file(self, file_path: Path) -> None:
        """Save the config to file."""
        to_save: dict[str, dict[str, Any]] = json.loads(file_path.read_text()) if file_path.exists() else {}

        fld: Field
        for fld in fields(self):
            default_value: Any = fld.default
            if fld.default_factory is not MISSING:
                default_value = fld.default_factory()

            on_save: Callable[[Any], Any] = fld.metadata.get("on_save", lambda x: x)
            to_save[fld.name] = {
                "description": fld.metadata.get("description", ""),
                "default": on_save(default_value),
                "value": on_save(getattr(self, fld.name)),
            }

        file_path.parent.mkdir(parents=True, exist_ok=True)
        file_path.write_text(json.dumps(to_save, indent=4))

    @classmethod
    def load_from_file(cls, file_path: Path) -> Self:
        """Load config from file.

        :param file_path: The path to the config file.
        :return: The config instance.
        """
        if not file_path.exists():
            instance: Self = cls()
            instance.save_to_file(file_path)
            return instance

        loaded: dict[str, Any] = {}
        with contextlib.suppress(OSError):
            loaded = {k: v["value"] for k, v in json.loads(file_path.read_text()).items() if "value" in v}

        kwargs: dict[str, Any] = {}
        fld: Field
        for fld in fields(cls):
            if fld.name in loaded:
                on_load: Callable[[Any], Any] = fld.metadata.get("on_load", lambda x: x)
                kwargs[fld.name] = on_load(loaded[fld.name])

        instance: Self = cls(**kwargs)
        instance.save_to_file(file_path)
        return instance


class Service[CONFIG: ServiceConfig](ABC):
    """Abstract base class for a Windows service."""

    svc_name: str | None
    """The name of the service to give to Windows."""
    svc_version: str | None = None
    """The version of the service to pass to *argparse*."""
    svc_display_name: str | None = None
    """The display name of the service to give to Windows."""
    svc_description: str | None = None
    """The description name of the service to give to Windows."""
    svc_dependencies: Sequence[str] | None = None
    """The list of dependencies of the service to give to Windows."""

    config_class: type[CONFIG]

    def __init__(self, args: Sequence[str]) -> None:
        """Create an instance of the *PyService*.

        :param args: The arguments that were passed to the *PyService*.
        """
        if getattr(sys, "frozen", False):  # pragma: no cover
            exe_file: Path = Path(sys.executable).parent
            os.chdir(exe_file)

        self.args: Sequence[str] = args

        self.config_file: Path = Path(f"./{self.svc_name}.json")
        self.config: CONFIG = self.config_class.load_from_file(self.config_file)

        log_config: dict[str, Any] = dict(self.config.log_config)
        logging.config.dictConfig(log_config)

        self._timer: Timer | None = None
        self._stop_flag: Event = Event()
        self._stop_flag.set()

        self._executor: Executor | None = None

    @property
    def running(self) -> bool:
        """Return True if the service has started and is running."""
        return not self._stop_flag.is_set()

    def start(self) -> None:
        """Start the service."""
        logger.info("Service starting . . .")
        try:
            if not self.on_start():
                logger.warning("Service start was aborted.")
                return
        except Exception as e:
            logger.critical("Unhandled exception during 'on_start':", exc_info=e)
            return

        logger.info("Service started")
        self._stop_flag.clear()
        self._start_timer(0)

        self._stop_flag.wait()
        logger.info("Service stopping . . .")
        self._stop_timer()
        self._stop_executor()
        try:
            self.on_stop()
        except Exception as e:  # noqa: BLE001
            logger.warning("Unhandled exception during 'on_stop':", exc_info=e)

        logger.info("Service stopped")

    def stop(self) -> None:
        """Stop the service."""
        self._stop_flag.set()

    def _run(self) -> None:
        start_time: int = time.perf_counter_ns()
        try:
            logger.info("Service Running . . .")
            self.on_run()

            if self.running:
                total_time: float = (time.perf_counter_ns() - start_time) / 1e9
                self.on_run_success(total_time)
        except Exception as exc:  # noqa: BLE001
            if self.running:
                total_time: float = (time.perf_counter_ns() - start_time) / 1e9
                self.on_run_failed(total_time, exc)

        if self.running:
            now: datetime = datetime.now()  # noqa: DTZ005
            next_start: datetime = now + self.config.frequency
            if next_start >= now:
                logger.info("Process will start at: %s", next_start.isoformat())
                diff: timedelta = next_start - now
                self._start_timer(diff.total_seconds())
            else:
                self.stop()

    def _start_timer(self, interval: float) -> None:
        if self.running:
            if self._timer is not None:  # pragma: no cover
                self._timer.cancel()
            self._timer = Timer(interval, self._run)
            self._timer.name = "ProcessThread"
            self._timer.daemon = True
            self._timer.start()
        elif self._timer is not None:  # pragma: no cover
            self._timer.cancel()

    def _stop_timer(self) -> None:
        if self._timer is not None:
            try:
                self._timer.cancel()
                self._timer.join()
            except RuntimeError:  # pragma: no cover
                pass
            self._timer = None

    def _stop_executor(self) -> None:
        if self._executor is not None:
            self._executor.shutdown(cancel_futures=True)
            self._executor = None

    @abstractmethod
    def on_start(self) -> bool:
        """Service tasks to do when the service is starting.

        :return: True if the service has successfully started, False otherwise.
        """

    @abstractmethod
    def on_run(self) -> None:
        """Service tasks to do when the timer elapses."""

    def on_run_success(self, time_taken: float) -> None:
        """Service tasks to do on a successful run.

        :param time_taken: The time in seconds that the run took.
        """
        logger.info("Service finished in %.3f seconds", time_taken)

    def on_run_failed(self, time_taken: float, exc: Exception) -> None:
        """Service tasks to do on a successful run.

        :param time_taken: The time in seconds that the run took.
        :param exc: The exception that was raised.
        """
        logger.critical(
            "An unhandled Exception caused the service to fail after %.3f seconds:",
            time_taken,
            exc_info=exc,
        )

    @abstractmethod
    def on_stop(self) -> None:
        """Service tasks to do when the service is stopping."""

    @contextlib.contextmanager
    def executor(
        self,
        max_workers: int | None = None,
        thread_name_prefix: str = "Worker",
        initializer: Callable[..., object] | None = None,
        initargs: tuple = (),
    ) -> Generator[Executor | None]:
        """Context manager to create a *ThreadPoolExecutor*."""
        try:
            if not self.running:
                yield None
                return

            self._executor = ThreadPoolExecutor(
                max_workers=max_workers,
                thread_name_prefix=thread_name_prefix,
                initializer=initializer,
                initargs=initargs,
            )
            yield self._executor
        except (CancelledError, RuntimeError):
            # This happens when the service is stopped before all Futures are finished so some
            # are canceled. When you try to get the result of a canceled Future, it raises a
            # CancelledError.
            pass
        finally:
            self._executor = None

    @classmethod
    def run(cls, *args: Any) -> CommandResult:
        """Run command line with arguments."""
        exit_code: CommandResult = SUCCESS
        try:
            commands: Mapping[str, ServiceCommand] = cls.__gather_commands()

            parser: ArgumentParser = cls.__create_command_parser(commands)
            parsed_args: Namespace = parser.parse_args(args)

            command_name: str | None = parsed_args.command
            if command_name in commands:
                command: ServiceCommand = commands[command_name]
                exit_code = command(parsed_args)
            else:
                cls.__win_service(command_name, *args)
        except ArgumentError as e:
            # Raised when ArgumentParser fails to parse the arguments.
            logger.exception("Argparse error:", exc_info=e)
            exit_code = ARGUMENT_ERROR
        except SystemExit:
            # ArgParser exited, either error or help/version
            exit_code = ARGPARSE_EXIT
        except BaseException as e:
            logger.exception("Unhandled exception:", exc_info=e)
            exit_code = FAILURE

        return exit_code

    @classmethod
    def handle_main(cls) -> NoReturn:
        """Handle main."""
        args: list[str] = sys.argv[1:]
        result: CommandResult = cls.run(*args)
        sys.exit(result)

    @classmethod
    def __gather_commands(cls) -> Mapping[str, ServiceCommand]:
        return {
            name.removeprefix(COMMAND_PREFIX): method
            for name, method in inspect.getmembers(cls, predicate=inspect.ismethod)
            if name.startswith(COMMAND_PREFIX) and hasattr(method, "command")
        }

    @classmethod
    def __create_command_parser(cls, commands: Mapping[str, ServiceCommand]) -> ArgumentParser:
        prog: str = f"{cls.svc_name}.exe" if getattr(sys, "frozen", False) else f"{cls.svc_name}.py"
        parser: ArgumentParser = ArgumentParser(prog=prog, description=cls.svc_description, exit_on_error=False)

        if cls.svc_version is not None:
            parser.add_argument("-v", "--version", action="version", version=cls.svc_version)

        # Re-Create win32serviceutil.HandleCommandLine commands so we can provide custom arguments
        argument_group: _ArgumentGroup = parser.add_argument_group("options for 'install' and 'update' commands only")
        argument_group.add_argument(
            "-u",
            "--username",
            metavar="DOMAIN\\USERNAME",
            help="the username the service is to run under",
        )
        argument_group.add_argument(
            "-p",
            "--password",
            help="the password for the username",
        )
        argument_group.add_argument(
            "-s",
            "--startup",
            choices=["manual", "auto", "disabled", "delayed"],
            help="how the service starts, default = manual",
        )
        argument_group.add_argument(
            "-i",
            "--interactive",
            action="store_true",
            help="allow the service to interact with the desktop",
        )
        argument_group.add_argument(
            "-ini",
            "--perf_mon_ini",
            type=Path,
            metavar="FILE",
            help="file to use for registering performance monitor data",
        )
        argument_group.add_argument(
            "-dll",
            "--perf_mon_dll",
            type=Path,
            metavar="FILE",
            help="file to use when querying the service for performance data, default = perfmondata.dll",
        )
        argument_group.add_argument(
            "-w",
            "--wait",
            type=int,
            default=0,
            metavar="SECONDS",
            help=(
                "wait for the service to actually start or stop. If you specify --wait with "
                "the 'stop' option, the service and all dependent services will be stopped, "
                "each waiting the specified period"
            ),
        )

        command_parser: _SubParsersAction = parser.add_subparsers(
            dest="command",
            metavar="command",
        )

        # Win32 Defined Commands
        command_parser.add_parser(
            "install",
            help="install the service",
        )
        command_parser.add_parser(
            "remove",
            help="remove the service",
        )
        command_parser.add_parser(
            "update",
            help="update the service",
        )
        command_parser.add_parser(
            "stop",
            help="stop the service",
        )

        sub_parser: ArgumentParser
        sub_parser = command_parser.add_parser(
            "start",
            help="start the service",
        )
        sub_parser.add_argument(
            "arguments",
            nargs=REMAINDER,
            help="arguments passed to service constructor",
        )

        sub_parser = command_parser.add_parser(
            "restart",
            help="stops, then starts the service",
        )
        sub_parser.add_argument(
            "arguments",
            nargs=REMAINDER,
            help="arguments passed to service constructor",
        )

        sub_parser = command_parser.add_parser(
            "debug",
            help="runs the service in debug mode",
        )
        sub_parser.add_argument(
            "arguments",
            nargs=REMAINDER,
            help="arguments passed to service constructor",
        )

        # User Defined Commands
        command_name: str | None
        command: ServiceCommand
        for command_name, command in commands.items():
            user_command_parser: ArgumentParser = command_parser.add_parser(command_name, **command.command.kwargs)
            argument: Argument
            for argument in command.command.args:
                user_command_parser.add_argument(*argument.args, **argument.kwargs)

        return parser

    @classmethod
    def __win_service(cls, command_name: str | None, *args: Any) -> None:
        global WindowsService  # noqa: PLW0603
        import win32service  # ty:ignore[unresolved-import]  # noqa: PLC0415
        import win32serviceutil  # noqa: PLC0415

        # noinspection PyRedeclaration
        class WindowsService(win32serviceutil.ServiceFramework):  # pragma: no cover
            _svc_name_: str = cls.svc_name or cls.__name__
            _svc_display_name_: str = cls.svc_display_name or cls.__name__
            _svc_description_: str = cls.svc_description or ""
            _svc_deps_: Sequence[str] | None = cls.svc_dependencies

            def __init__(self, args: Sequence[str]) -> None:
                super().__init__(args)
                self.py_service = cls(args)

            def SvcRun(self) -> None:  # noqa: N802
                self.ReportServiceStatus(win32service.SERVICE_START_PENDING)
                if getattr(sys, "frozen", False):
                    exe_file: Path = Path(sys.executable).parent
                    os.chdir(exe_file)

                self.ReportServiceStatus(win32service.SERVICE_RUNNING)
                self.py_service.start()
                self.ReportServiceStatus(win32service.SERVICE_STOPPED)

            def SvcStop(self) -> None:  # noqa: N802
                self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
                self.py_service.stop()

        if command_name is None:
            import servicemanager  # ty:ignore[unresolved-import]  # noqa: PLC0415

            servicemanager.Initialize()
            servicemanager.PrepareToHostSingle(WindowsService)
            servicemanager.StartServiceCtrlDispatcher()
        else:
            win32serviceutil.HandleCommandLine(WindowsService, argv=(sys.argv[0], *args))

    @classmethod
    @ServiceCommand.create(
        Argument("arguments", nargs=REMAINDER, help="arguments passed to service constructor"),
        help="runs the service without needing to be installed",
    )
    def command_start_local(cls, arguments: Sequence[str]) -> CommandResult:
        """Command to run the service locally instead of as a service."""
        instance: Service = cls(arguments)
        try:
            thread: Thread = Thread(target=instance.start, name="StartThread")
            thread.start()
            while thread.is_alive():
                time.sleep(1)
        except BaseException as e:  # noqa: BLE001  # pragma: no cover
            # This mostly handles KeyboardInterrupt, but we want to report the exception anyway
            instance.stop()
            return str(e)
        return SUCCESS

    @classmethod
    @ServiceCommand.create(
        Argument("arguments", nargs=REMAINDER, help="arguments passed to service constructor"),
        help="generate the batch files",
    )
    def command_gen_batch(cls, arguments: Sequence[str]) -> CommandResult:
        """Command to generate batch files for standard service operations."""
        logger.info("Generating batch files . . .")

        cls(arguments)

        name: str
        contents: str
        for name, contents in {
            "Install": f'cd /d "%~dp0"\n{cls.svc_name} install\ntimeout 5\n',
            "Remove": f'cd /d "%~dp0"\n{cls.svc_name} remove\ntimeout 5\n',
            "Start": f'cd /d "%~dp0"\n{cls.svc_name} start\ntimeout 5\n',
            "Stop": f'cd /d "%~dp0"\n{cls.svc_name} stop\ntimeout 5\n',
            "Restart": f'cd /d "%~dp0"\n{cls.svc_name} restart\ntimeout 5\n',
        }.items():
            file: Path = Path(f"./{cls.svc_name}_{name}.bat")
            file.write_text(contents)
        return SUCCESS
