import io
import platform
import subprocess
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass
from os import PathLike
from pathlib import Path
from typing import IO, Sequence


@dataclass
class Properties:
    """Dataclass used to store various parameters used for creating a new subprocess.

    All attributes have the same naming and the same usage as mentioned in Python's subprocess.Popen class.

    :var args: A set of arguments needed to start a new subprocess.
        (default is ...)
    :type args: str | bytes | PathLike[str] | PathLike[bytes] | Sequence[str | bytes | PathLike[str] | PathLike[bytes]]
    :var stdin: An object to handle input data stream.
        (default is None)
    :type stdin: None | int | IO = None
    :var stdout: An object to handle output data stream.
        (default is None)
    :type stdout: None | int | IO = None
    :var cwd: Customizable path for current working directory, to determine where will this script be started from.
        (default is ...)
    :type cwd: str | bytes | PathLike[str] | PathLike[bytes]
    :var shell: If True, the command is going to be executed through the shell.
        (default is ...)
    :type shell: bool
    """

    args: str | bytes | PathLike[str] | PathLike[bytes] | Sequence[str | bytes | PathLike[str] | PathLike[bytes]] = ...
    stdin: None | int | IO = None
    stdout: None | int | IO = None
    cwd: str | bytes | PathLike[str] | PathLike[bytes] = ...
    shell: bool = platform.system() == "Windows"


class ScriptHandler(ABC):
    """Abstract class that contains some basic logic related to running external scripts.

    :var properties: Properties that will be used to launch a subprocess.
    :type properties: Properties
    :var subprocess: Actual subprocess that will be executed afterwards.
    :type subprocess: subprocess.Popen
    """

    subprocess: subprocess.Popen
    properties: Properties

    @abstractmethod
    def set_properties(self, script: Path, args: str | bytes | PathLike[str] | PathLike[bytes] | Sequence[str | bytes |
                       PathLike[str] | PathLike[bytes]] = None):
        """Specifying properties that will be used at the time of launching the actual subprocess.

        :param script: Full path to the Python script that should be executed here.
        :type script: Path
        :param args: Any other extra arguments (if any) that needed to be added to the launch command.
            (default is None)
        :type args: str | bytes | PathLike[str] | PathLike[bytes] | Sequence[str | bytes | PathLike[str] |
            PathLike[bytes]]
        """

        ...

    def create_subprocess(self):
        """Creating subprocess."""

        # Using properties stored as dataclass object to initialize new subprocess.Popen object.
        self.subprocess = subprocess.Popen(**asdict(self.properties))

    @abstractmethod
    def wait_for_finish(self):
        """Waiting for the current subprocess to finish, so we can proceed to the next one if requested."""

        ...

    def execute(self):
        """Actually executing specified command."""

        # Creating the subprocess based on the custom properties provided beforehand.
        self.create_subprocess()

        # Waiting for this subprocess to finish.
        self.wait_for_finish()


class PythonScriptHandler(ScriptHandler):
    """Used to execute external Python scripts.

    :var properties: Properties that will be used to launch a subprocess.
    :type properties: Properties
    :var subprocess: Actual subprocess that will be executed afterwards.
    :type subprocess: subprocess.Popen
    :var python_alias: Alias for Python that will be used at the time of executing the script.
    :type python_alias: str | bytes | PathLike[str] | PathLike[bytes]
    """

    def __init__(self, python_alias: str | bytes | PathLike[str] | PathLike[bytes]):
        """Class constructor.

        :param python_alias: Alias for Python that will be used at the time of executing the script.
        :type python_alias: str | bytes | PathLike[str] | PathLike[bytes]
        """

        self.python_alias = python_alias

    def set_properties(self, script: Path, args: str | bytes | PathLike[str] | PathLike[bytes] | Sequence[str | bytes |
                       PathLike[str] | PathLike[bytes]] = None):
        # Executing the designated Python script via specified Python executable.
        # Extra arguments will be provided only if there are any.
        self.properties = Properties(
            args=[self.python_alias, script, *args] if args else [self.python_alias, script],
            cwd=script.parent
        )

    def wait_for_finish(self):
        # Default waiting process, will be marked as finished when the target subprocess terminates.
        self.subprocess.wait()


class BatchFileHandler(ScriptHandler):
    """Used to execute batch script files.

    :var properties: Properties that will be used to launch a subprocess.
    :type properties: Properties
    :var subprocess: Actual subprocess that will be executed afterwards.
    :type subprocess: subprocess.Popen
    :var BATCH_PAUSE_KEYWORD: Keyword that is used to determine a pause in a batch file's execution process.
    :type BATCH_PAUSE_KEYWORD: str
    :var CRLF: Encoded line of characters that represent CRLF (Carriage Return & Line Feed).
    :type CRLF: bytes
    """

    def __init__(self):
        """Class constructor."""

        self.BATCH_PAUSE_KEYWORD = "pause"
        self.CRLF = "\r\n".encode('utf-8')

    def set_properties(self, script: Path, args: str | bytes | PathLike[str] | PathLike[bytes] | Sequence[str | bytes |
                       PathLike[str] | PathLike[bytes]] = None):
        # Executing target batch script file.
        # Extra arguments will be provided only if there are any.
        # Setting up custom stdin/stdout channels, so we can control them later.
        self.properties = Properties(
            args=[script, *args] if args else [script],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            cwd=script.parent
        )

    def wait_for_finish(self):
        # Checking for the subprocess' output line by line.
        for line in io.TextIOWrapper(self.subprocess.stdout):

            # Checking if we've got a pause keyword in the output, so that means the process has been finished.
            # That is needed because target scripts don't terminate automatically, so we need to do that manually.
            if self.BATCH_PAUSE_KEYWORD not in line:

                # If the process is not done yet, printing output's current line as on-screen logs,
                # and waiting to get the next line from the output.
                # That is needed because Python doesn't print batch execution logs automatically.
                print(line)
                continue

            # If we've finally met the pause keyword, we're going to ask the process for termination.
            # To do that, we're sending the CR/LF via stdin channel, then closing it and interrupting the loop.
            self.subprocess.stdin.write(self.CRLF)
            self.subprocess.stdin.close()
            break
