# Copyright 2016 Red Hat | Ansible
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!

from __future__ import annotations

import base64
import datetime
import io
import json
import os
import os.path
import shutil
import stat
import tarfile
import typing as t

from ansible.module_utils.common.text.converters import to_bytes, to_text

from ansible_collections.community.docker.plugins.module_utils._api.errors import (
    APIError,
    NotFound,
)

if t.TYPE_CHECKING:
    from collections.abc import Callable

    from _typeshed import WriteableBuffer

    from ansible_collections.community.docker.plugins.module_utils._api.api.client import (
        APIClient,
    )


class DockerFileCopyError(Exception):
    pass


class DockerUnexpectedError(DockerFileCopyError):
    pass


class DockerFileNotFound(DockerFileCopyError):
    pass


def _put_archive(
    client: APIClient, container: str, path: str, data: bytes | t.Generator[bytes]
) -> bool:
    # data can also be file object for streaming. This is because _put uses requests's put().
    # See https://requests.readthedocs.io/en/latest/user/advanced/#streaming-uploads
    url = client._url("/containers/{0}/archive", container)
    res = client._put(url, params={"path": path}, data=data)
    client._raise_for_status(res)
    return res.status_code == 200


def _symlink_tar_creator(
    b_in_path: bytes,
    file_stat: os.stat_result,
    out_file: str | bytes,
    user_id: int,
    group_id: int,
    mode: int | None = None,
    user_name: str | None = None,
) -> bytes:
    if not stat.S_ISLNK(file_stat.st_mode):
        raise DockerUnexpectedError("stat information is not for a symlink")
    bio = io.BytesIO()
    with tarfile.open(
        fileobj=bio, mode="w|", dereference=False, encoding="utf-8"
    ) as tar:
        # Note that without both name (bytes) and arcname (unicode), this either fails for
        # Python 2.7, Python 3.5/3.6, or Python 3.7+. Only when passing both (in this
        # form) it works with Python 2.7, 3.5, 3.6, and 3.7 up to 3.11
        tarinfo = tar.gettarinfo(b_in_path, arcname=to_text(out_file))
        tarinfo.uid = user_id
        tarinfo.uname = ""
        if user_name:
            tarinfo.uname = user_name
        tarinfo.gid = group_id
        tarinfo.gname = ""
        tarinfo.mode &= 0o700
        if mode is not None:
            tarinfo.mode = mode
        if not tarinfo.issym():
            raise DockerUnexpectedError("stat information is not for a symlink")
        tar.addfile(tarinfo)
    return bio.getvalue()


def _symlink_tar_generator(
    b_in_path: bytes,
    file_stat: os.stat_result,
    out_file: str | bytes,
    user_id: int,
    group_id: int,
    mode: int | None = None,
    user_name: str | None = None,
) -> t.Generator[bytes]:
    yield _symlink_tar_creator(
        b_in_path, file_stat, out_file, user_id, group_id, mode, user_name
    )


def _regular_file_tar_generator(
    b_in_path: bytes,
    file_stat: os.stat_result,
    out_file: str | bytes,
    user_id: int,
    group_id: int,
    mode: int | None = None,
    user_name: str | None = None,
) -> t.Generator[bytes]:
    if not stat.S_ISREG(file_stat.st_mode):
        raise DockerUnexpectedError("stat information is not for a regular file")
    tarinfo = tarfile.TarInfo()
    tarinfo.name = (
        os.path.splitdrive(to_text(out_file))[1].replace(os.sep, "/").lstrip("/")
    )
    tarinfo.mode = (file_stat.st_mode & 0o700) if mode is None else mode
    tarinfo.uid = user_id
    tarinfo.gid = group_id
    tarinfo.size = file_stat.st_size
    tarinfo.mtime = file_stat.st_mtime
    tarinfo.type = tarfile.REGTYPE
    tarinfo.linkname = ""
    if user_name:
        tarinfo.uname = user_name

    tarinfo_buf = tarinfo.tobuf()
    total_size = len(tarinfo_buf)
    yield tarinfo_buf

    size = tarinfo.size
    total_size += size
    with open(b_in_path, "rb") as f:
        while size > 0:
            to_read = min(size, 65536)
            buf = f.read(to_read)
            if not buf:
                break
            size -= len(buf)
            yield buf
    if size:
        # If for some reason the file shrunk, fill up to the announced size with zeros.
        # (If it enlarged, ignore the remainder.)
        yield tarfile.NUL * size

    remainder = tarinfo.size % tarfile.BLOCKSIZE
    if remainder:
        # We need to write a multiple of 512 bytes. Fill up with zeros.
        yield tarfile.NUL * (tarfile.BLOCKSIZE - remainder)
        total_size += tarfile.BLOCKSIZE - remainder

    # End with two zeroed blocks
    yield tarfile.NUL * (2 * tarfile.BLOCKSIZE)
    total_size += 2 * tarfile.BLOCKSIZE

    remainder = total_size % tarfile.RECORDSIZE
    if remainder > 0:
        yield tarfile.NUL * (tarfile.RECORDSIZE - remainder)


def _regular_content_tar_generator(
    content: bytes,
    out_file: str | bytes,
    user_id: int,
    group_id: int,
    mode: int,
    user_name: str | None = None,
) -> t.Generator[bytes]:
    tarinfo = tarfile.TarInfo()
    tarinfo.name = (
        os.path.splitdrive(to_text(out_file))[1].replace(os.sep, "/").lstrip("/")
    )
    tarinfo.mode = mode
    tarinfo.uid = user_id
    tarinfo.gid = group_id
    tarinfo.size = len(content)
    tarinfo.mtime = int(datetime.datetime.now().timestamp())
    tarinfo.type = tarfile.REGTYPE
    tarinfo.linkname = ""
    if user_name:
        tarinfo.uname = user_name

    tarinfo_buf = tarinfo.tobuf()
    total_size = len(tarinfo_buf)
    yield tarinfo_buf

    total_size += len(content)
    yield content

    remainder = tarinfo.size % tarfile.BLOCKSIZE
    if remainder:
        # We need to write a multiple of 512 bytes. Fill up with zeros.
        yield tarfile.NUL * (tarfile.BLOCKSIZE - remainder)
        total_size += tarfile.BLOCKSIZE - remainder

    # End with two zeroed blocks
    yield tarfile.NUL * (2 * tarfile.BLOCKSIZE)
    total_size += 2 * tarfile.BLOCKSIZE

    remainder = total_size % tarfile.RECORDSIZE
    if remainder > 0:
        yield tarfile.NUL * (tarfile.RECORDSIZE - remainder)


def put_file(
    client: APIClient,
    container: str,
    in_path: str,
    out_path: str,
    user_id: int,
    group_id: int,
    mode: int | None = None,
    user_name: str | None = None,
    follow_links: bool = False,
) -> None:
    """Transfer a file from local to Docker container."""
    if not os.path.exists(to_bytes(in_path, errors="surrogate_or_strict")):
        raise DockerFileNotFound(f"file or module does not exist: {to_text(in_path)}")

    b_in_path = to_bytes(in_path, errors="surrogate_or_strict")

    out_dir, out_file = os.path.split(out_path)

    if follow_links:
        file_stat = os.stat(b_in_path)
    else:
        file_stat = os.lstat(b_in_path)

    if stat.S_ISREG(file_stat.st_mode):
        stream = _regular_file_tar_generator(
            b_in_path,
            file_stat,
            out_file,
            user_id,
            group_id,
            mode=mode,
            user_name=user_name,
        )
    elif stat.S_ISLNK(file_stat.st_mode):
        stream = _symlink_tar_generator(
            b_in_path,
            file_stat,
            out_file,
            user_id,
            group_id,
            mode=mode,
            user_name=user_name,
        )
    else:
        file_part = " referenced by" if follow_links else ""
        raise DockerFileCopyError(
            f"File{file_part} {in_path} is neither a regular file nor a symlink (stat mode {oct(file_stat.st_mode)})."
        )

    ok = _put_archive(client, container, out_dir, stream)
    if not ok:
        raise DockerUnexpectedError(
            f'Unknown error while creating file "{out_path}" in container "{container}".'
        )


def put_file_content(
    client: APIClient,
    container: str,
    content: bytes,
    out_path: str,
    user_id: int,
    group_id: int,
    mode: int,
    user_name: str | None = None,
) -> None:
    """Transfer a file from local to Docker container."""
    out_dir, out_file = os.path.split(out_path)

    stream = _regular_content_tar_generator(
        content, out_file, user_id, group_id, mode, user_name=user_name
    )

    ok = _put_archive(client, container, out_dir, stream)
    if not ok:
        raise DockerUnexpectedError(
            f'Unknown error while creating file "{out_path}" in container "{container}".'
        )


def stat_file(
    client: APIClient,
    container: str,
    in_path: str,
    follow_links: bool = False,
    log: Callable[[str], None] | None = None,
) -> tuple[str, dict[str, t.Any] | None, str | None]:
    """Fetch information on a file from a Docker container to local.

    Return a tuple ``(path, stat_data, link_target)`` where:

    :path: is the resolved path in case ``follow_links=True``;
    :stat_data: is ``None`` if the file does not exist, or a dictionary with fields
        ``name`` (string), ``size`` (integer), ``mode`` (integer, see https://pkg.go.dev/io/fs#FileMode),
        ``mtime`` (string), and ``linkTarget`` (string);
    :link_target: is ``None`` if the file is not a symlink or when ``follow_links=False``,
        and a string with the symlink target otherwise.
    """
    considered_in_paths = set()

    while True:
        if in_path in considered_in_paths:
            raise DockerFileCopyError(
                f"Found infinite symbolic link loop when trying to stating {in_path!r}"
            )
        considered_in_paths.add(in_path)

        if log:
            log(f"FETCH: Stating {in_path!r}")

        response = client._head(
            client._url("/containers/{0}/archive", container),
            params={"path": in_path},
        )
        if response.status_code == 404:
            return in_path, None, None
        client._raise_for_status(response)
        header = response.headers.get("x-docker-container-path-stat")
        try:
            if header is None:
                raise ValueError("x-docker-container-path-stat header not present")
            stat_data = json.loads(base64.b64decode(header))
        except Exception as exc:
            raise DockerUnexpectedError(
                f"When retrieving information for {in_path} from {container}, obtained header {header!r} that cannot be loaded as JSON: {exc}"
            ) from exc

        # https://pkg.go.dev/io/fs#FileMode: bit 32 - 5 means ModeSymlink
        if stat_data["mode"] & (1 << (32 - 5)) != 0:
            link_target = stat_data["linkTarget"]
            if not follow_links:
                return in_path, stat_data, link_target
            in_path = os.path.join(os.path.split(in_path)[0], link_target)
            continue

        return in_path, stat_data, None


class _RawGeneratorFileobj(io.RawIOBase):
    def __init__(self, stream: t.Generator[bytes]):
        self._stream = stream
        self._buf = b""

    def readable(self) -> bool:
        return True

    def _readinto_from_buf(self, b: WriteableBuffer, index: int, length: int) -> int:
        cpy = min(length - index, len(self._buf))
        if cpy:
            b[index : index + cpy] = self._buf[:cpy]  # type: ignore # TODO!
            self._buf = self._buf[cpy:]
            index += cpy
        return index

    def readinto(self, b: WriteableBuffer) -> int:
        index = 0
        length = len(b)  # type: ignore # TODO!

        index = self._readinto_from_buf(b, index, length)
        if index == length:
            return index

        try:
            self._buf += next(self._stream)
        except StopIteration:
            return index

        return self._readinto_from_buf(b, index, length)


def _stream_generator_to_fileobj(stream: t.Generator[bytes]) -> io.BufferedReader:
    """Given a generator that generates chunks of bytes, create a readable buffered stream."""
    raw = _RawGeneratorFileobj(stream)
    return io.BufferedReader(raw)


_T = t.TypeVar("_T")


def fetch_file_ex(
    client: APIClient,
    container: str,
    in_path: str,
    process_none: Callable[[str], _T],
    process_regular: Callable[[str, tarfile.TarFile, tarfile.TarInfo], _T],
    process_symlink: Callable[[str, tarfile.TarInfo], _T],
    process_other: Callable[[str, tarfile.TarInfo], _T],
    follow_links: bool = False,
    log: Callable[[str], None] | None = None,
) -> _T:
    """Fetch a file (as a tar file entry) from a Docker container to local."""
    considered_in_paths: set[str] = set()

    while True:
        if in_path in considered_in_paths:
            raise DockerFileCopyError(
                f'Found infinite symbolic link loop when trying to fetch "{in_path}"'
            )
        considered_in_paths.add(in_path)

        if log:
            log(f'FETCH: Fetching "{in_path}"')
        try:
            stream = client.get_raw_stream(
                "/containers/{0}/archive",
                container,
                params={"path": in_path},
                headers={"Accept-Encoding": "identity"},
            )
        except NotFound:
            return process_none(in_path)

        with tarfile.open(
            fileobj=_stream_generator_to_fileobj(stream), mode="r|"
        ) as tar:
            symlink_member: tarfile.TarInfo | None = None
            result: _T | None = None
            found = False
            for member in tar:
                if found:
                    raise DockerUnexpectedError(
                        "Received tarfile contains more than one file!"
                    )
                found = True
                if member.issym():
                    symlink_member = member
                    continue
                if member.isfile():
                    result = process_regular(in_path, tar, member)
                    continue
                result = process_other(in_path, member)
            if symlink_member:
                if not follow_links:
                    return process_symlink(in_path, symlink_member)
                in_path = os.path.join(
                    os.path.split(in_path)[0], symlink_member.linkname
                )
                if log:
                    log(f'FETCH: Following symbolic link to "{in_path}"')
                continue
            if found:
                return result  # type: ignore
            raise DockerUnexpectedError("Received tarfile is empty!")


def fetch_file(
    client: APIClient,
    container: str,
    in_path: str,
    out_path: str,
    follow_links: bool = False,
    log: Callable[[str], None] | None = None,
) -> str:
    b_out_path = to_bytes(out_path, errors="surrogate_or_strict")

    def process_none(in_path: str) -> str:
        raise DockerFileNotFound(
            f"File {in_path} does not exist in container {container}"
        )

    def process_regular(
        in_path: str, tar: tarfile.TarFile, member: tarfile.TarInfo
    ) -> str:
        if not follow_links and os.path.exists(b_out_path):
            os.unlink(b_out_path)

        reader = tar.extractfile(member)
        if reader:
            with reader as in_f, open(b_out_path, "wb") as out_f:
                shutil.copyfileobj(in_f, out_f)
        return in_path

    def process_symlink(in_path: str, member: tarfile.TarInfo) -> str:
        if os.path.exists(b_out_path):
            os.unlink(b_out_path)

        os.symlink(member.linkname, b_out_path)
        return in_path

    def process_other(in_path: str, member: tarfile.TarInfo) -> str:
        raise DockerFileCopyError(
            f'Remote file "{in_path}" is not a regular file or a symbolic link'
        )

    return fetch_file_ex(
        client,
        container,
        in_path,
        process_none,
        process_regular,
        process_symlink,
        process_other,
        follow_links=follow_links,
        log=log,
    )


def _execute_command(
    client: APIClient,
    container: str,
    command: list[str],
    log: Callable[[str], None] | None = None,
    check_rc: bool = False,
) -> tuple[int, bytes, bytes]:
    if log:
        log(f"Executing {command} in {container}")

    data = {
        "Container": container,
        "User": "",
        "Privileged": False,
        "Tty": False,
        "AttachStdin": False,
        "AttachStdout": True,
        "AttachStderr": True,
        "Cmd": command,
    }

    if "detachKeys" in client._general_configs:
        data["detachKeys"] = client._general_configs["detachKeys"]

    try:
        exec_data = client.post_json_to_json(
            "/containers/{0}/exec", container, data=data
        )
    except NotFound as e:
        raise DockerFileCopyError(f'Could not find container "{container}"') from e
    except APIError as e:
        if e.response is not None and e.response.status_code == 409:
            raise DockerFileCopyError(
                f'Cannot execute command in paused container "{container}"'
            ) from e
        raise
    exec_id = exec_data["Id"]

    data = {"Tty": False, "Detach": False}
    stdout, stderr = client.post_json_to_stream(
        "/exec/{0}/start", exec_id, stream=False, demux=True, tty=False
    )

    result = client.get_json("/exec/{0}/json", exec_id)

    rc: int = result.get("ExitCode") or 0
    stdout = stdout or b""
    stderr = stderr or b""

    if log:
        log(f"Exit code {rc}, stdout {stdout!r}, stderr {stderr!r}")

    if check_rc and rc != 0:
        command_str = " ".join(command)
        raise DockerUnexpectedError(
            f'Obtained unexpected exit code {rc} when running "{command_str}" in {container}.\nSTDOUT: {stdout!r}\nSTDERR: {stderr!r}'
        )

    return rc, stdout, stderr


def determine_user_group(
    client: APIClient, container: str, log: Callable[[str], None] | None = None
) -> tuple[int, int]:
    dummy_rc, stdout, dummy_stderr = _execute_command(
        client, container, ["/bin/sh", "-c", "id -u && id -g"], check_rc=True, log=log
    )

    stdout_lines = stdout.splitlines()
    if len(stdout_lines) != 2:
        raise DockerUnexpectedError(
            f"Expected two-line output to obtain user and group ID for container {container}, but got {len(stdout_lines)} lines:\n{stdout!r}"
        )

    user_id, group_id = stdout_lines
    try:
        return int(user_id), int(group_id)
    except ValueError as exc:
        raise DockerUnexpectedError(
            f"Expected two-line output with numeric IDs to obtain user and group ID for container {container}, but got {user_id!r} and {group_id!r} instead"
        ) from exc
