import json
import os
import pwd
import random
import string
import time
from datetime import datetime
from enum import Enum
from functools import cached_property
from pathlib import Path
from typing import Dict, Optional, Union, cast

import humanize
import psutil

from hapless import config
from hapless.utils import allow_missing, get_mtime, logger


class Status(str, Enum):
    # Created status
    UNBOUND = "unbound"
    # Active statuses
    PAUSED = "paused"
    RUNNING = "running"
    # Finished statuses
    FAILED = "failed"
    SUCCESS = "success"


class Hap(object):
    def __init__(
        self,
        hap_path: Path,
        *,
        name: Optional[str] = None,
        cmd: Optional[str] = None,
        env: Optional[Dict[str, str]] = None,
        workdir: Optional[Union[str, Path]] = None,
        redirect_stderr: bool = False,
    ) -> None:
        if not hap_path.is_dir():
            raise ValueError(f"Path {hap_path} is not a directory")

        self._hap_path = hap_path
        self._hid: str = hap_path.name

        self._pid_file = hap_path / "pid"
        self._rc_file = hap_path / "rc"
        self._name_file = hap_path / "name"
        self._cmd_file = hap_path / "cmd"
        self._workdir_file = hap_path / "workdir"
        self._env_file = hap_path / "env"

        self._stdout_path = hap_path / "stdout.log"
        self._stderr_path = hap_path / "stderr.log"

        self._set_logfiles(redirect_stderr)
        self._set_raw_name(name)
        self._set_command_context(cmd, workdir)
        self._set_env(env)

    def set_name(self, name: str):
        with open(self._name_file, "w") as f:
            f.write(name)

    def set_return_code(self, rc: int):
        with open(self._rc_file, "w") as f:
            f.write(f"{rc}")

    def _set_raw_name(self, raw_name: Optional[str]) -> None:
        """
        Set name for the first time on hap creation.
        """
        if raw_name is None:
            suffix = self.get_random_name()
            raw_name = f"hap-{suffix}"

        if self.raw_name is None:
            self.set_name(raw_name)

    def _set_command_context(
        self,
        cmd: Optional[str],
        workdir: Optional[Union[str, Path]],
    ) -> None:
        """
        Set command and working directory for the first time on hap creation.
        """
        if self.cmd is None:
            if cmd is None:
                raise ValueError("Command to run is not provided")
            with open(self._cmd_file, "w") as f:
                f.write(cmd)

        if self.workdir is None:
            workdir = Path(workdir or os.getcwd())
            # NOTE: should be ValueError, but we need defaults to keep
            # compatibility with the older versions
            if not workdir.exists() or not workdir.is_dir():
                raise ValueError("Workdir should be a path to existing directory")

            with open(self._workdir_file, "w") as f:
                f.write(f"{workdir}")

    def _set_pid(self, pid: int):
        with open(self._pid_file, "w") as pid_file:
            pid_file.write(f"{pid}")

        if not psutil.pid_exists(pid):
            raise RuntimeError(f"Process with pid {pid} is gone")

    def _set_logfiles(self, redirect_stderr: bool) -> None:
        if redirect_stderr:
            logger.debug("Process stderr will be redirected to stdout file")
        if not self._stdout_path.exists() and not redirect_stderr:
            self._stderr_path.touch(exist_ok=True, mode=0o644)
        self._stdout_path.touch(exist_ok=True, mode=0o644)

    def _get_proc_env(self) -> Dict[str, str]:
        proc = self.proc
        environ = {}
        if proc is not None:
            try:
                environ = proc.environ()
            except (ProcessLookupError, psutil.NoSuchProcess) as e:
                logger.error(f"Cannot get environment: {e}")
        return environ

    def _set_env(self, env: Optional[Dict[str, str]] = None) -> None:
        """
        Set environment variables for the first time on hap creation.
        """
        if self.env is None:
            if env is None:
                env = dict(os.environ)

            with open(self._env_file, "w") as env_file:
                env_file.write(json.dumps(env))

    def bind(self, pid: int):
        """
        Associate hap object with existing process by pid.
        """
        try:
            self._set_pid(pid)
        except (RuntimeError, psutil.AccessDenied) as e:
            logger.error(f"Cannot bind due to {e}")

    @staticmethod
    def get_random_name(length: int = 6) -> str:
        return "".join(
            random.sample(
                string.ascii_lowercase + string.digits,
                length,
            )
        )

    # TODO: add extended status to show panel proc.status()
    @property
    def status(self) -> Status:
        if self.pid is None and self.rc is None:
            # No existing process or no return code from the finished one
            return Status.UNBOUND

        proc = self.proc
        if proc is not None:
            if proc.status() == psutil.STATUS_STOPPED:
                return Status.PAUSED
            return Status.RUNNING

        if self.rc != 0:
            return Status.FAILED

        return Status.SUCCESS

    @cached_property
    def proc(self):
        # NOTE: this is cached for the instance lifetime, fits our use case
        if self.pid is None:
            return

        try:
            return psutil.Process(self.pid)
        except psutil.NoSuchProcess as e:
            logger.warning(f"Cannot find process: {e}")

    @property
    @allow_missing
    def cmd(self) -> Optional[str]:
        with open(self._cmd_file) as f:
            return f.read()

    @property
    @allow_missing
    def workdir(self) -> Optional[Path]:
        with open(self._workdir_file) as f:
            return Path(f.read())

    @property
    @allow_missing
    def rc(self) -> Optional[int]:
        with open(self._rc_file) as f:
            return int(f.read())

    @property
    def runtime(self) -> str:
        proc = self.proc
        runtime = 0
        if proc is not None:
            runtime = time.time() - proc.create_time()
        elif self._pid_file.exists():
            start_time = cast(float, get_mtime(self._pid_file))
            finish_time = cast(
                float, get_mtime(self._rc_file) or get_mtime(self.stderr_path)
            )
            runtime = finish_time - start_time

        return humanize.naturaldelta(runtime)

    @property
    def start_time(self) -> Optional[str]:
        if self._pid_file.exists():
            return datetime.fromtimestamp(os.path.getmtime(self._pid_file)).strftime(
                config.DATETIME_FORMAT
            )
        logger.info("No start time as hap has not started yet")

    @property
    def end_time(self) -> Optional[str]:
        if self._rc_file.exists():
            return datetime.fromtimestamp(os.path.getmtime(self._rc_file)).strftime(
                config.DATETIME_FORMAT
            )
        logger.info("No end time as hap has not finished yet")

    @property
    def active(self) -> bool:
        return self.proc is not None

    @property
    def hid(self) -> str:
        return self._hid

    @property
    @allow_missing
    def pid(self) -> Optional[int]:
        with open(self._pid_file) as f:
            return int(f.read())

    @property
    @allow_missing
    def env(self) -> Optional[Dict[str, str]]:
        environ = self._get_proc_env()

        if environ:
            return environ

        with open(self._env_file) as f:
            return json.loads(f.read())

    @property
    @allow_missing
    def raw_name(self) -> Optional[str]:
        with open(self._name_file) as f:
            return f.read().strip()

    @cached_property
    def name(self) -> str:
        """
        Base name without restarts counter.
        """
        raw_name = self.raw_name
        if raw_name is None:
            raise ValueError("Hap state is corrupted")

        return raw_name.split(config.RESTART_DELIM)[0]

    @cached_property
    def restarts(self) -> int:
        """
        Obtain number of restarts from the raw name.
        """
        raw_name = self.raw_name
        if raw_name is None:
            raise ValueError("Hap state is corrupted")

        _, *rest = raw_name.rsplit("@", maxsplit=1)
        return int(rest[0]) if rest else 0

    @property
    def path(self) -> Path:
        return self._hap_path

    @property
    def stdout_path(self) -> Path:
        return self._stdout_path

    @property
    def stderr_path(self) -> Path:
        if self.redirect_stderr:
            return self._stdout_path
        return self._stderr_path

    @property
    def redirect_stderr(self) -> bool:
        return not self._stderr_path.exists()

    @property
    def accessible(self) -> bool:
        """
        Check if current user has control over the hap.
        NOTE: EAFP is preferable here instead
        https://docs.python.org/3/library/os.html#os.access
        """
        return os.access(self.path, os.F_OK | os.R_OK | os.W_OK | os.X_OK)

    @property
    def owner(self) -> str:
        stat = self.path.stat()
        try:
            owner = pwd.getpwuid(stat.st_uid).pw_name
        except KeyError:
            owner = f"{stat.st_uid}:{stat.st_gid}"
        return owner

    def serialize(self) -> dict:
        """
        Serialize hap object into a dictionary.
        """
        return {
            "hid": self.hid,
            "name": self.name,
            "pid": str(self.pid) if self.pid is not None else None,
            "rc": str(self.rc) if self.rc is not None else None,
            "cmd": self.cmd,
            "workdir": str(self.workdir),
            "status": self.status.value,
            "runtime": self.runtime,
            "start_time": self.start_time,
            "end_time": self.end_time,
            "restarts": str(self.restarts),
            "stdout_file": str(self.stdout_path),
            "stderr_file": str(self.stderr_path),
        }

    def __str__(self) -> str:
        return f"#{self.hid} ({self.name})"

    def __repr__(self) -> str:
        return f"<{self.__class__.__name__} {self} object at {hex(id(self))}>"

    def __rich__(self) -> str:
        pid_text = f"with PID [[{config.COLOR_MAIN} bold]{self.pid}[/]]"
        rich_text = (
            f"hap {config.ICON_HAP}{self.hid} "
            f"([{config.COLOR_MAIN} bold]{self.name}[/])"
        )
        if self.pid:
            return f"{rich_text} {pid_text}"
        return rich_text
