"""Host-to-container path mapping utility.

Translates file paths between the host filesystem and the container
workspace for file-based tool arguments and outputs.

Used by :class:`ContainerToolExecutor` to ensure file paths in tool
inputs point to the correct locations inside the container, and that
output paths are mapped back to the host sandbox.

Based on issue #515 — container-aware tool execution and I/O forwarding.
"""

from __future__ import annotations

import posixpath
from dataclasses import dataclass


@dataclass(frozen=True)
class PathMapper:
    """Bi-directional path mapper between host and container filesystems.

    Attributes:
        host_root: Absolute path to the host-side sandbox root.
        container_root: Absolute path to the container-side workspace root.

    Example::

        mapper = PathMapper(
            host_root="/tmp/sandbox/project-1",
            container_root="/workspace",
        )
        h2c = mapper.host_to_container(
            "/tmp/sandbox/project-1/src/main.py",
        )
        assert h2c == "/workspace/src/main.py"
        c2h = mapper.container_to_host("/workspace/src/main.py")
        assert c2h == "/tmp/sandbox/project-1/src/main.py"
    """

    host_root: str
    container_root: str

    def __post_init__(self) -> None:
        """Validate and normalise root paths."""
        if "\x00" in self.host_root or "\x00" in self.container_root:
            raise ValueError("Root paths must not contain null bytes")
        if not self.host_root or not self.container_root:
            raise ValueError("Root paths must not be empty")

        normalised_host = _normalise(self.host_root)
        normalised_container = _normalise(self.container_root)

        # ``posixpath.normpath("//")`` returns ``"//"`` per POSIX, so
        # we must guard against both ``"/"`` and ``"//"`` to prevent a
        # root-mapping bypass.
        if normalised_host in ("/", "//"):
            raise ValueError(
                "host_root must not be '/' — it would map every absolute path"
            )
        if normalised_container in ("/", "//"):
            raise ValueError(
                "container_root must not be '/' — it would map every absolute path"
            )

        # Reject overlapping roots which produce corrupt mappings where
        # a relative-path component gets doubled (P2-14).
        if _is_under(normalised_host, normalised_container) or _is_under(
            normalised_container, normalised_host
        ):
            raise ValueError(
                "host_root and container_root must not overlap: "
                f"{normalised_host!r} vs {normalised_container!r}"
            )

        # Store normalised roots to avoid trailing-slash inconsistencies.
        object.__setattr__(self, "host_root", normalised_host)
        object.__setattr__(self, "container_root", normalised_container)

    def host_to_container(self, host_path: str) -> str:
        """Map a host path to the equivalent container path.

        If *host_path* is not under :attr:`host_root`, it is returned
        unchanged (external paths cannot be mapped).

        Args:
            host_path: Absolute path on the host filesystem.

        Returns:
            Equivalent path inside the container, or *host_path* if
            it falls outside the host root.

        Raises:
            ValueError: If *host_path* contains null bytes.
        """
        _reject_null_bytes(host_path)
        normalised = _normalise(host_path)

        if not _is_under(normalised, self.host_root):
            return normalised

        relative = _relative_to(normalised, self.host_root)
        return (
            posixpath.join(self.container_root, relative)
            if relative
            else self.container_root
        )

    def container_to_host(self, container_path: str) -> str:
        """Map a container path to the equivalent host path.

        If *container_path* is not under :attr:`container_root`, it is
        returned unchanged.

        Args:
            container_path: Absolute path inside the container.

        Returns:
            Equivalent path on the host filesystem, or *container_path*
            if it falls outside the container root.

        Raises:
            ValueError: If *container_path* contains null bytes.
        """
        _reject_null_bytes(container_path)
        normalised = _normalise(container_path)

        if not _is_under(normalised, self.container_root):
            return normalised

        relative = _relative_to(normalised, self.container_root)
        return posixpath.join(self.host_root, relative) if relative else self.host_root

    def is_host_path(self, path: str) -> bool:
        """Check whether *path* is under the host root."""
        if "\x00" in path:
            return False
        return _is_under(_normalise(path), self.host_root)

    def is_container_path(self, path: str) -> bool:
        """Check whether *path* is under the container root."""
        if "\x00" in path:
            return False
        return _is_under(_normalise(path), self.container_root)


# ---------------------------------------------------------------------------
# Path helpers (use posixpath for container paths which are always Linux)
# ---------------------------------------------------------------------------


def _reject_null_bytes(path: str) -> None:
    """Raise ``ValueError`` if *path* contains null bytes."""
    if "\x00" in path:
        raise ValueError(f"Path contains null bytes: {path!r}")


def _normalise(path: str) -> str:
    """Normalise a path — strip trailing slashes, collapse double slashes."""
    return posixpath.normpath(path)


def _is_under(path: str, root: str) -> bool:
    """Return ``True`` if *path* is equal to or a child of *root*."""
    if path == root:
        return True
    return path.startswith(root + "/")


def _relative_to(path: str, root: str) -> str:
    """Return the part of *path* relative to *root*.

    Assumes :func:`_is_under` has already been checked.
    """
    if path == root:
        return ""
    return path[len(root) + 1 :]
