from __future__ import annotations

import os
import re
import shlex
import subprocess
import sys
import warnings
from contextlib import suppress
from shutil import which
from time import sleep
from typing import TYPE_CHECKING, ClassVar, TextIO, cast

from streamlink.compat import is_win32
from streamlink.exceptions import StreamlinkWarning
from streamlink.logger import getLogger
from streamlink_cli.output.abc import Output
from streamlink_cli.utils import Formatter


if TYPE_CHECKING:
    from collections.abc import Mapping, Sequence
    from pathlib import Path

    from streamlink.utils.named_pipe import NamedPipeBase
    from streamlink_cli.output.file import FileOutput
    from streamlink_cli.output.http import HTTPOutput


log = getLogger("streamlink.cli.output")


class PlayerArgsMeta(type):
    PLAYERS: ClassVar[list[type[PlayerArgs]]] = []

    def __init__(cls, name, bases, attrs, **kwargs):
        super().__init__(name, bases, attrs, **kwargs)
        if attrs.get("NAME"):
            cls.PLAYERS.append(cast("type[PlayerArgs]", cls))


class PlayerArgs(metaclass=PlayerArgsMeta):
    NAME: ClassVar[str] = ""
    EXECUTABLE: ClassVar[re.Pattern | None] = None
    FLATPAK: ClassVar[str | None] = None

    def __new__(cls, path: Path, *_, args: str = "", **__):
        executable = path.name.lower()
        is_flatpak = False

        if is_win32 and executable[-4:] == ".exe":
            executable = executable[:-4]
        elif (
            path.name.lower() == "flatpak"
            and args
            and (parsed := shlex.split(args))
            and (fp_idx := cls._get_flatpak_args_app_index(parsed))
        ):
            is_flatpak = True
            executable = parsed[fp_idx]

        playerargs = cls
        for player in cls.PLAYERS:
            if (
                player.EXECUTABLE is not None
                and player.EXECUTABLE.match(executable)
                or is_flatpak
                and player.FLATPAK is not None
                and player.FLATPAK == executable
            ):
                playerargs = player
                break

        return super().__new__(playerargs)

    def __init__(
        self,
        path: Path,
        args: str = "",
        title: str | None = None,
        filename: str | None = None,
        namedpipe: NamedPipeBase | None = None,
        http: HTTPOutput | None = None,
    ):
        self.path = path
        self.args = args
        self.title = title

        self._has_var_playerinput = f"{{{PlayerOutput.PLAYER_ARGS_INPUT}}}" in args
        self._has_var_playertitleargs = f"{{{PlayerOutput.PLAYER_ARGS_TITLE}}}" in args

        if namedpipe:
            self._input = self.get_namedpipe(namedpipe)
        elif filename:
            self._input = self.get_filename(filename)
        elif http:
            self._input = self.get_http(http)
        else:
            self._input = self.get_stdin()

    @classmethod
    def get_player_names(cls) -> list[str]:
        return sorted(
            (p.NAME for p in cls.PLAYERS),
            key=lambda s: s.lower(),
        )

    @staticmethod
    def _get_flatpak_args_app_index(args: list[str]) -> int:
        found_run_cmd = False

        for i, arg in enumerate(args):
            if not found_run_cmd and arg == "run":
                found_run_cmd = True
            elif found_run_cmd and not arg.startswith("-"):
                return i

        return 0

    def build(self) -> list[str]:
        args_title = []
        if self.title is not None:
            args_title.extend(self.get_title(self.title))

        # format args via the formatter, so that invalid/unknown variables don't raise a KeyError
        argsformatter = Formatter({
            PlayerOutput.PLAYER_ARGS_INPUT: lambda: subprocess.list2cmdline([self._input]),
            PlayerOutput.PLAYER_ARGS_TITLE: lambda: subprocess.list2cmdline(args_title),
        })
        args = argsformatter.title(self.args)
        args_tokenized = shlex.split(args)

        if not self._has_var_playertitleargs:
            if self.path.name.lower() == "flatpak" and (fp_idx := self._get_flatpak_args_app_index(args_tokenized)):
                args_tokenized = [*args_tokenized[: fp_idx + 1], *args_title, *args_tokenized[fp_idx + 1 :]]
            else:
                args_tokenized = [*args_title, *args_tokenized]
        if not self._has_var_playerinput:
            args_tokenized.append(self._input)

        return [str(self.path), *args_tokenized]

    # noinspection PyMethodMayBeStatic
    def get_stdin(self) -> str:
        return "-"

    def get_namedpipe(self, namedpipe: NamedPipeBase) -> str:
        return str(namedpipe.path)

    # noinspection PyMethodMayBeStatic
    def get_filename(self, filename: str) -> str:
        return filename

    # noinspection PyMethodMayBeStatic
    def get_http(self, http: HTTPOutput) -> str:
        return http.url

    def get_title(self, title: str) -> list[str]:
        return []


class PlayerArgsVLC(PlayerArgs):
    NAME = "VLC"
    EXECUTABLE = re.compile(r"^vlc$")
    FLATPAK = "org.videolan.VLC"

    def get_namedpipe(self, namedpipe: NamedPipeBase) -> str:
        if is_win32:
            return f"stream://\\{namedpipe.path}"

        return super().get_namedpipe(namedpipe)

    def get_title(self, title) -> list[str]:
        title = title.replace("$", "$$")

        return ["--input-title-format", title]


class PlayerArgsMPV(PlayerArgs):
    NAME = "mpv"
    EXECUTABLE = re.compile(r"^mpv$")
    FLATPAK = "io.mpv.Mpv"

    def get_namedpipe(self, namedpipe: NamedPipeBase) -> str:
        if is_win32:
            return f"file://{namedpipe.path}"

        return super().get_namedpipe(namedpipe)

    def get_title(self, title: str) -> list[str]:
        return [f"--force-media-title={title}"]


class PlayerArgsPotplayer(PlayerArgs):
    NAME = "PotPlayer"
    EXECUTABLE = re.compile(r"^potplayer(?:mini(?:64)?)?$")

    def get_title(self, title: str) -> list[str]:
        if self._input != "-":
            # PotPlayer CLI help:
            # "You can specify titles for URLs by separating them with a backslash (\) at the end of URLs."
            self._input = f"{self._input}\\{title}"

        return []


class PlayerOutput(Output):
    PLAYER_TERMINATE_TIMEOUT = 10.0

    PLAYER_ARGS_INPUT = "playerinput"
    PLAYER_ARGS_TITLE = "playertitleargs"

    playerargs: PlayerArgs
    player: subprocess.Popen
    stdin: int | TextIO
    stdout: int | TextIO
    stderr: int | TextIO

    def __init__(
        self,
        path: Path,
        args: str = "",
        env: Sequence[tuple[str, str]] | None = None,
        quiet: bool = True,
        kill: bool = True,
        call: bool = False,
        filename: str | None = None,
        namedpipe: NamedPipeBase | None = None,
        http: HTTPOutput | None = None,
        record: FileOutput | None = None,
        title: str | None = None,
    ):
        super().__init__()

        self.path = path
        self.args = args
        self.env: Mapping[str, str] = dict(env or {})

        self.kill = kill
        self.call = call
        self.quiet = quiet

        self.player = None  # type: ignore[assignment, ty:invalid-assignment]
        self.filename = filename
        self.namedpipe = namedpipe
        self.http = http
        self.record = record

        self.title = title

        self.playerargs = PlayerArgs(
            path=path,
            args=args,
            title=title,
            namedpipe=namedpipe,
            filename=filename,
            http=http,
        )

        if self.namedpipe or self.filename or self.http:
            self.stdin = sys.stdin
        else:
            self.stdin = subprocess.PIPE

        if self.quiet:
            self.stdout = subprocess.DEVNULL
            self.stderr = subprocess.DEVNULL
        else:
            self.stdout = sys.stdout
            self.stderr = sys.stderr

    @property
    def running(self):
        sleep(0.5)
        return self.player.poll() is None

    def _open(self):
        args = self.playerargs.build()

        playerpath = args[0]
        if resolved := which(playerpath):
            args[0] = resolved
        else:
            if playerpath[:1] in ('"', "'"):
                warnings.warn(
                    "\n".join([
                        "The --player argument has been changed and now only takes player path values:",
                        "  Player paths must not be wrapped in additional quotation marks",
                        "  and custom player arguments need to be set via --player-args.",
                        "  This is most likely caused by using an old config file from an ealier Streamlink version.",
                        "  Please see the migration guides in Streamlink's documentation:",
                        "  https://streamlink.github.io/migrations.html#player-path-only-player-cli-argument",
                    ]),
                    StreamlinkWarning,
                    stacklevel=1,
                )

            raise FileNotFoundError("Player executable not found")

        if self.record:
            self.record.open()
        if self.call and self.filename:
            self._open_call(args)
        else:
            self._open_subprocess(args)

    def _open_call(self, args: list[str]):
        if self.env:
            log.debug("Calling: %r, env: %r", args, self.env)
        else:
            log.debug("Calling: %r", args)

        environ = dict(os.environ)
        environ.update(self.env)

        subprocess.call(
            args,
            env=environ,
            stdout=self.stdout,
            stderr=self.stderr,
        )

    def _open_subprocess(self, args: list[str]):
        if self.env:
            log.debug("Opening subprocess: %r, env: %r", args, self.env)
        else:
            log.debug("Opening subprocess: %r", args)

        environ = dict(os.environ)
        environ.update(self.env)

        # Force bufsize=0 on all Python versions to avoid writing the
        # unflushed buffer when closing a broken input pipe
        self.player = subprocess.Popen(
            args,
            bufsize=0,
            env=environ,
            stdin=self.stdin,
            stdout=self.stdout,
            stderr=self.stderr,
        )
        # Wait 0.5 seconds to see if program exited prematurely
        if not self.running:
            raise OSError("Process exited prematurely")

        if self.namedpipe:
            self.namedpipe.open()
        elif self.http:
            self.http.accept_connection()
            self.http.open()

    def close(self):
        # Close input to the player first to signal the end of the stream and allow the player to terminate on its own.
        # Always close the player input, regardless whether the player was not launched yet
        if self.namedpipe:
            self.namedpipe.close()
            self.namedpipe = None
        elif self.http:
            self.http.shutdown()
            self.http = None
        elif not self.filename and self.player and self.player.stdin:  # pragma: no branch
            self.player.stdin.close()

        super().close()

    def _close(self):
        if self.record:
            self.record.close()

        if self.kill:
            with suppress(Exception):
                self.player.terminate()
                if not is_win32:
                    t, timeout = 0.0, self.PLAYER_TERMINATE_TIMEOUT
                    while self.player.poll() is None and t < timeout:
                        sleep(0.5)
                        t += 0.5

                    if not self.player.returncode:
                        self.player.kill()
        self.player.wait()

    def _write(self, data):
        if self.record:
            self.record.write(data)

        if self.namedpipe:
            self.namedpipe.write(data)
        elif self.http:
            self.http.write(data)
        elif self.player.stdin:  # pragma: no branch
            self.player.stdin.write(data)
