import subprocess
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator, Callable, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, Optional, cast

from trio import run_process

from .utils import RunArg, T, async_cached


class OS(ABC):
    @abstractmethod
    def switch_(self, *, linux: Callable[["Linux"], T], macos: Callable[[], T]) -> T:
        pass

    def switch(self, *, linux: T, macos: T) -> T:
        return self.switch_(linux=lambda _: linux, macos=lambda: macos)


@dataclass
class Linux(OS):
    distribution: str

    def switch_(self, *, linux: Callable[["Linux"], T], macos: Callable[[], T]) -> T:
        return linux(self)

    RELEASE_FILE = "/etc/os-release"

    @classmethod
    async def get_distribution(cls, driver: "Driver") -> str:
        release_file = await driver.run_output("cat", Path(cls.RELEASE_FILE))
        for line in release_file.splitlines():
            k, v = line.split("=", 1)
            if k == "ID":
                if v.startswith('"') and v.endswith('"'):
                    v = v[1:-1]
                return v

        raise ValueError(f"Cannot find distribution ID in {cls.RELEASE_FILE}.")


@dataclass
class MacOS(OS):
    def switch_(self, *, linux: Callable[["Linux"], T], macos: Callable[[], T]) -> T:
        return macos()


Architecture = Literal["x86_64", "aarch64"]


@dataclass
class RunResult:
    ok: bool


@dataclass
class RunResultOutput(RunResult):
    ok: bool
    output: str


class Driver(ABC):
    @abstractmethod
    async def run_(
        self,
        *args: RunArg,
        check: bool = True,
        input: Optional[bytes] = None,  # pylint:disable=redefined-builtin
        capture_output: bool = False,
        silent: bool = False,
    ) -> RunResult:
        pass

    async def run(
        self,
        *args: RunArg,
        input: Optional[bytes] = None,  # pylint:disable=redefined-builtin
        silent: bool = False,
    ) -> None:
        await self.run_(*args, input=input, silent=silent)

    async def run_ok(self, *args: RunArg) -> bool:
        result = await self.run_(*args, check=False)
        return result.ok

    async def run_output(self, *args: RunArg, silent: bool = False) -> str:
        result = await self.run_(*args, capture_output=True, silent=silent)
        return cast(RunResultOutput, result).output

    async def run_output_(self, *args: RunArg) -> RunResultOutput:
        result = await self.run_(*args, capture_output=True, check=False, silent=True)
        return cast(RunResultOutput, result)

    async def executable_exists(self, executable: str) -> bool:
        return await self.run_ok("sh", "-c", f"command -v {executable}")

    async def find_executable(self, *executables: str) -> str:
        for candidate in executables:
            if await self.executable_exists(candidate):
                return candidate
        raise Exception(f"None of {', '.join(executables)} found in PATH.")

    async def is_file(self, path: Path) -> bool:
        return await self.run_ok("test", "-f", path)

    async def is_executable(self, path: Path) -> bool:
        return await self.run_ok("test", "-x", path)

    async def is_dir(self, path: Path) -> bool:
        return await self.run_ok("test", "-d", path)

    async def username(self) -> str:
        return await self.run_output("whoami")

    @async_cached
    async def home(self) -> Path:
        result = await self.run_output("sh", "-c", "eval echo ~")
        return Path(result)

    @asynccontextmanager
    async def tempfile(
        self, kind: Optional[Literal["directory"]] = None
    ) -> AsyncIterator[Path]:
        args = []
        if kind == "directory":
            args.append("-d")

        path = Path(await self.run_output("mktemp", *args))
        try:
            yield path
        finally:
            await self.rm(path)

    async def makedirs(self, path: Path) -> None:
        await self.run("mkdir", "-p", path)

    async def copy(self, source: Path, target: Path) -> None:
        await self.makedirs(target.parent)
        await self.rm(target)
        await self.run("cp", "-R", "-f", source, target)

    async def rm(self, path: Path) -> None:
        await self.run("rm", "-r", "-f", path)

    async def make_executable(self, path: Path) -> None:
        await self.run("chmod", "+x", path)

    async def write_file(self, path: Path, content: str) -> None:
        await self.makedirs(path.parent)
        await self.rm(path)
        await self.run("cp", "/dev/stdin", path, input=content.encode())

    async def link(
        self,
        source: Path,
        target: Path,
    ) -> None:
        await self.makedirs(target.parent)
        await self.rm(target)
        await self.run("ln", "-s", "-f", source, target)

    @async_cached
    async def os(self) -> OS:
        os_type = await self.run_output("uname")
        if os_type == "Linux":
            distribution = await Linux.get_distribution(self)
            return Linux(distribution=distribution)
        elif os_type == "Darwin":
            return MacOS()
        else:
            raise ValueError(f"Unsupported OS type {os_type}.")  # pragma: no cover

    @async_cached
    async def architecture(self) -> Architecture:
        result = await self.run_output("uname", "-m")
        if result == "x86_64":
            return "x86_64"
        elif result in {"arm64", "aarch64"}:
            return "aarch64"
        else:
            raise ValueError(f"Unsupported architecture {result}.")  # pragma: no cover


class LocalDriver(Driver):
    def prepare_command(self, args: Sequence[RunArg]) -> list[RunArg]:
        return list(args)

    async def run_(
        self,
        *args: RunArg,
        check: bool = True,
        input: Optional[bytes] = None,  # pylint:disable=redefined-builtin
        capture_output: bool = False,
        silent: bool = False,
    ) -> RunResult:
        command = self.prepare_command(args)

        if not check or silent:
            stderr = subprocess.DEVNULL
        else:
            stderr = None

        result = await run_process(
            command,
            check=check,
            stdin=input,
            capture_stdout=True,
            stderr=stderr,
            **self.run_args(),
        )

        ok = result.returncode == 0
        if capture_output:
            output = result.stdout.decode().strip()
            return RunResultOutput(ok=ok, output=output)
        else:
            return RunResult(ok=ok)

    def run_args(self) -> dict[str, Any]:
        return {}
