from __future__ import annotations

import uuid
from typing import Optional

import allure
from neofs_testlib.shell import Shell
from neofs_testlib.shell.interfaces import CommandOptions
from tenacity import retry, stop_after_attempt, wait_fixed


class RemoteProcess:
    def __init__(self, cmd: str, process_dir: str, shell: Shell):
        self.process_dir = process_dir
        self.cmd = cmd
        self.stdout_last_line_number = 0
        self.stderr_last_line_number = 0
        self.pid: Optional[str] = None
        self.proc_rc: Optional[int] = None
        self.saved_stdout: Optional[str] = None
        self.saved_stderr: Optional[str] = None
        self.shell = shell

    @classmethod
    @allure.step("Create remote process")
    def create(cls, command: str, shell: Shell) -> RemoteProcess:
        """
        Create a process on a remote host.

        Created dir for process with following files:
            command.sh: script to execute
            pid: contains process id
            rc: contains script return code
            stderr: contains script errors
            stdout: contains script output

        Args:
            shell: Shell instance
            command: command to be run on a remote host

        Returns:
            RemoteProcess instance for further examination
        """
        remote_process = cls(cmd=command, process_dir=f"/tmp/proc_{uuid.uuid4()}", shell=shell)
        remote_process._create_process_dir()
        remote_process._generate_command_script(command)
        remote_process._start_process()
        remote_process.pid = remote_process._get_pid()
        return remote_process

    @allure.step("Get process stdout")
    def stdout(self, full: bool = False) -> str:
        """
        Method to get process stdout, either fresh info or full.

        Args:
            full: returns full stdout that we have to this moment

        Returns:
            Fresh stdout. By means of stdout_last_line_number only new stdout lines are returned.
            If process is finished (proc_rc is not None) saved stdout is returned
        """
        if self.saved_stdout is not None:
            cur_stdout = self.saved_stdout
        else:
            terminal = self.shell.exec(f"cat {self.process_dir}/stdout")
            if self.proc_rc is not None:
                self.saved_stdout = terminal.stdout
            cur_stdout = terminal.stdout

        if full:
            return cur_stdout
        whole_stdout = cur_stdout.split("\n")
        if len(whole_stdout) > self.stdout_last_line_number:
            resulted_stdout = "\n".join(whole_stdout[self.stdout_last_line_number :])
            self.stdout_last_line_number = len(whole_stdout)
            return resulted_stdout
        return ""

    @allure.step("Get process stderr")
    def stderr(self, full: bool = False) -> str:
        """
        Method to get process stderr, either fresh info or full.

        Args:
            full: returns full stderr that we have to this moment

        Returns:
            Fresh stderr. By means of stderr_last_line_number only new stderr lines are returned.
            If process is finished (proc_rc is not None) saved stderr is returned
        """
        if self.saved_stderr is not None:
            cur_stderr = self.saved_stderr
        else:
            terminal = self.shell.exec(f"cat {self.process_dir}/stderr")
            if self.proc_rc is not None:
                self.saved_stderr = terminal.stdout
            cur_stderr = terminal.stdout
        if full:
            return cur_stderr
        whole_stderr = cur_stderr.split("\n")
        if len(whole_stderr) > self.stderr_last_line_number:
            resulted_stderr = "\n".join(whole_stderr[self.stderr_last_line_number :])
            self.stderr_last_line_number = len(whole_stderr)
            return resulted_stderr
        return ""

    @allure.step("Get process rc")
    def rc(self) -> Optional[int]:
        if self.proc_rc is not None:
            return self.proc_rc

        terminal = self.shell.exec(f"cat {self.process_dir}/rc", CommandOptions(check=False))
        if "No such file or directory" in terminal.stderr:
            return None
        elif terminal.stderr or terminal.return_code != 0:
            raise AssertionError(f"cat process rc was not successfull: {terminal.stderr}")

        self.proc_rc = int(terminal.stdout)
        return self.proc_rc

    @allure.step("Check if process is running")
    def running(self) -> bool:
        return self.rc() is None

    @allure.step("Send signal to process")
    def send_signal(self, signal: int) -> None:
        kill_res = self.shell.exec(f"kill -{signal} {self.pid}", CommandOptions(check=False))
        if "No such process" in kill_res.stderr:
            return
        if kill_res.return_code:
            raise AssertionError(
                f"Signal {signal} not sent. Return code of kill: {kill_res.return_code}"
            )

    @allure.step("Stop process")
    def stop(self) -> None:
        self.send_signal(15)

    @allure.step("Kill process")
    def kill(self) -> None:
        self.send_signal(9)

    @allure.step("Clear process directory")
    def clear(self) -> None:
        if self.process_dir == "/":
            raise AssertionError(f"Invalid path to delete: {self.process_dir}")
        self.shell.exec(f"rm -rf {self.process_dir}")

    @allure.step("Start remote process")
    def _start_process(self) -> None:
        self.shell.exec(
            f"nohup {self.process_dir}/command.sh </dev/null "
            f">{self.process_dir}/stdout "
            f"2>{self.process_dir}/stderr &"
        )

    @allure.step("Create process directory")
    def _create_process_dir(self) -> None:
        self.shell.exec(f"mkdir {self.process_dir}; chmod 777 {self.process_dir}")
        terminal = self.shell.exec(f"realpath {self.process_dir}")
        self.process_dir = terminal.stdout.strip()

    @allure.step("Get pid")
    @retry(wait=wait_fixed(10), stop=stop_after_attempt(5), reraise=True)
    def _get_pid(self) -> str:
        terminal = self.shell.exec(f"cat {self.process_dir}/pid")
        assert terminal.stdout, f"invalid pid: {terminal.stdout}"
        return terminal.stdout.strip()

    @allure.step("Generate command script")
    def _generate_command_script(self, command: str) -> None:
        command = command.replace('"', '\\"').replace("\\", "\\\\")
        script = (
            f"#!/bin/bash\n"
            f"cd {self.process_dir}\n"
            f"{command} &\n"
            f"pid=\$!\n"
            f"cd {self.process_dir}\n"
            f"echo \$pid > {self.process_dir}/pid\n"
            f"wait \$pid\n"
            f"echo $? > {self.process_dir}/rc"
        )

        self.shell.exec(f'echo "{script}" > {self.process_dir}/command.sh')
        self.shell.exec(f"cat {self.process_dir}/command.sh")
        self.shell.exec(f"chmod +x {self.process_dir}/command.sh")