"""E2E tests for djb.core.cmd_runner module.

Tests run real subprocess execution to validate:
- Input piping (stdin)
- Timeout enforcement
- Interactive mode (PTY)
- Shell mode
- Combined feature interactions
- PTY input forwarding

Use these tests as examles of parallel subprocess E2E testing.
See docs/testing-guide.md "Process-Based E2E Test Patterns" for more details.

Features:
1. PROCESS SYNCHRONIZATION (TestPtyInputForwarding._run_with_pty_input)
   - Wait for process output before sending input, not blind sleep()
   - Use select() with timeout for non-blocking reads

2. PTY TESTING (TestPtyInputForwarding)
   - Close slave FD in parent after fork to avoid hangs
   - Drain remaining output after process exits
   - Use stty -echo for password-style input (getpass reads /dev/tty directly)

3. ERROR HANDLING (TestErrorHandling)
   - Test specific exception types (FileNotFoundError, PermissionError)
   - Include command info in assertion messages for debugging

4. TIMEOUT BEHAVIOR (TestTimeoutEnforcement, TestInteractiveMode)
   - Verify timeout kills long processes in pipes mode
   - Verify interactive mode IGNORES timeout parameter

5. PLATFORM AWARENESS
   - Use @pytest.mark.skipif for PTY tests on non-Unix
   - Check _HAS_UNIX_PTY before PTY-dependent tests
"""

from __future__ import annotations

import os
import pty
import select
import subprocess  # noqa: TID251 - testing CmdRunner vs subprocess
import sys
import threading
import time
from pathlib import Path

import pytest

from djb.core.cmd_runner import (
    CmdRunner,
    CmdTimeout,
    _HAS_UNIX_PTY,
)


class TestInputParameter:
    """Tests for stdin piping via input= parameter."""

    @pytest.mark.parametrize("shell", [False, True])
    def test_input_pipes_to_stdin(self, runner: CmdRunner, tmp_path: Path, shell: bool):
        """input= parameter pipes data to subprocess stdin."""
        if shell:
            cmd = "cat"
        else:
            cmd = ["cat"]
        result = runner.run(cmd, input="hello\nworld", shell=shell, cwd=tmp_path)
        assert result.success
        assert "hello" in result.stdout
        assert "world" in result.stdout

    def test_input_with_multiline_data(self, runner: CmdRunner, tmp_path: Path):
        """input= handles multiline data correctly."""
        input_data = "line1\nline2\nline3\n"
        result = runner.run(["cat"], input=input_data, cwd=tmp_path)
        assert result.success
        assert "line1" in result.stdout
        assert "line2" in result.stdout
        assert "line3" in result.stdout

    def test_input_with_large_data(self, runner: CmdRunner, tmp_path: Path):
        """input= handles large data without hanging."""
        # 100KB of data - enough to fill pipe buffers
        large_input = "x" * 100_000
        result = runner.run(["cat"], input=large_input, cwd=tmp_path)
        assert result.success
        assert len(result.stdout.strip()) == 100_000

    def test_input_with_empty_string(self, runner: CmdRunner, tmp_path: Path):
        """input= with empty string closes stdin immediately."""
        result = runner.run(["cat"], input="", cwd=tmp_path)
        assert result.success
        assert result.stdout.strip() == ""

    def test_input_with_script_reading_stdin(self, runner: CmdRunner, tmp_path: Path):
        """input= works with scripts that read stdin."""
        script = tmp_path / "read_stdin.py"
        script.write_text(
            """
import sys
data = sys.stdin.read()
print(f"Read {len(data)} bytes")
print(data)
"""
        )
        result = runner.run([sys.executable, str(script)], input="test input data", cwd=tmp_path)
        assert result.success
        assert "Read 15 bytes" in result.stdout
        assert "test input data" in result.stdout


class TestTimeoutEnforcement:
    """Tests for timeout enforcement in pipes mode."""

    def test_timeout_kills_long_running_process(self, runner: CmdRunner, tmp_path: Path):
        """Timeout kills process that runs too long."""
        with pytest.raises(CmdTimeout) as exc_info:
            runner.run(["sleep", "10"], timeout=0.2, cwd=tmp_path)
        assert exc_info.value.timeout == 0.2
        assert "sleep" in str(exc_info.value.cmd)

    def test_short_command_completes_before_timeout(self, runner: CmdRunner, tmp_path: Path):
        """Short command completes successfully with generous timeout."""
        result = runner.run(["echo", "fast"], timeout=10.0, cwd=tmp_path)
        assert result.success
        assert "fast" in result.stdout

    def test_timeout_captures_partial_output(self, runner: CmdRunner, tmp_path: Path):
        """Timeout captures output produced before kill."""
        script = tmp_path / "slow_output.sh"
        script.write_text(
            """#!/bin/bash
echo "first line"
sleep 0.1
echo "second line"
sleep 10
echo "third line"
"""
        )
        script.chmod(0o755)

        with pytest.raises(CmdTimeout):
            runner.run([str(script)], timeout=0.3, cwd=tmp_path)

    @pytest.mark.parametrize("timeout", [0.1, 0.5, 1.0])
    def test_timeout_with_various_values(self, runner: CmdRunner, timeout: float, tmp_path: Path):
        """Timeout works with various timeout values."""
        start = time.time()
        with pytest.raises(CmdTimeout):
            runner.run(["sleep", "10"], timeout=timeout, cwd=tmp_path)
        elapsed = time.time() - start
        # Should complete within 2x the timeout (allowing for process cleanup)
        assert elapsed < timeout * 3

    def test_timeout_with_input(self, runner: CmdRunner, tmp_path: Path):
        """Timeout works together with input parameter."""
        script = tmp_path / "slow_reader.sh"
        script.write_text(
            """#!/bin/bash
cat  # Read stdin
sleep 10
"""
        )
        script.chmod(0o755)

        with pytest.raises(CmdTimeout):
            runner.run([str(script)], input="some input", timeout=0.2, cwd=tmp_path)


class TestInteractiveMode:
    """Tests for interactive mode (PTY execution)."""

    @pytest.mark.skipif(not _HAS_UNIX_PTY, reason="Unix PTY required")
    def test_interactive_mode_provides_tty(self, runner: CmdRunner, tmp_path: Path):
        """interactive=True provides a real TTY to subprocess."""
        script = tmp_path / "check_tty.py"
        script.write_text(
            """
import sys
print("stdin:", sys.stdin.isatty())
print("stdout:", sys.stdout.isatty())
print("stderr:", sys.stderr.isatty())
"""
        )
        result = runner.run([sys.executable, str(script)], interactive=True, cwd=tmp_path)
        assert result.success
        # In PTY mode, at least stdout should be a TTY
        assert "stdout: True" in result.stdout

    @pytest.mark.skipif(not _HAS_UNIX_PTY, reason="Unix PTY required")
    def test_interactive_mode_ignores_timeout(self, runner: CmdRunner, tmp_path: Path):
        """interactive=True ignores timeout parameter - commands must not be killed.

        This is critical: PTY mode doesn't pass timeout to _run_with_pty().
        Even if a short timeout is specified, interactive processes should complete.
        """
        script = tmp_path / "slow_interactive.py"
        script.write_text(
            """
import time
time.sleep(0.5)  # Sleep longer than the timeout
print("completed")
"""
        )
        # Use a very short timeout that would definitely kill a pipes-mode process
        result = runner.run(
            [sys.executable, str(script)],
            interactive=True,
            timeout=0.1,  # This should be IGNORED in interactive mode
            cwd=tmp_path,
        )
        assert result.success
        assert "completed" in result.stdout

    @pytest.mark.skipif(not _HAS_UNIX_PTY, reason="Unix PTY required")
    def test_interactive_mode_captures_output(self, runner: CmdRunner, tmp_path: Path):
        """interactive=True captures both stdout and stderr (combined)."""
        script = tmp_path / "mixed_output.py"
        script.write_text(
            """
import sys
print("stdout message", flush=True)
print("stderr message", file=sys.stderr, flush=True)
"""
        )
        result = runner.run([sys.executable, str(script)], interactive=True, cwd=tmp_path)
        assert result.success
        # In PTY mode, stdout and stderr are combined
        assert "stdout message" in result.stdout
        assert "stderr message" in result.stdout

    @pytest.mark.skipif(not _HAS_UNIX_PTY, reason="Unix PTY required")
    def test_interactive_mode_with_shell(self, runner: CmdRunner, tmp_path: Path):
        """interactive=True works with shell=True."""
        result = runner.run(
            "echo hello && echo world",
            interactive=True,
            shell=True,
            cwd=tmp_path,
        )
        assert result.success
        assert "hello" in result.stdout
        assert "world" in result.stdout

    @pytest.mark.skipif(not _HAS_UNIX_PTY, reason="Unix PTY required")
    def test_interactive_mode_exit_code(self, runner: CmdRunner, tmp_path: Path):
        """interactive=True preserves exit codes."""
        result = runner.run([sys.executable, "-c", "exit(42)"], interactive=True, cwd=tmp_path)
        assert result.returncode == 42


class TestShellMode:
    """Tests for shell=True mode."""

    def test_shell_mode_executes_commands(self, runner: CmdRunner, tmp_path: Path):
        """shell=True executes shell commands correctly."""
        result = runner.run("echo hello", shell=True, cwd=tmp_path)
        assert result.success
        assert "hello" in result.stdout

    def test_shell_mode_supports_pipes(self, runner: CmdRunner, tmp_path: Path):
        """shell=True supports shell pipes."""
        result = runner.run("echo 'hello world' | tr 'a-z' 'A-Z'", shell=True, cwd=tmp_path)
        assert result.success
        assert "HELLO WORLD" in result.stdout

    def test_shell_mode_supports_redirects(self, runner: CmdRunner, tmp_path: Path):
        """shell=True supports shell redirects."""
        result = runner.run(
            f"echo 'test content' > {tmp_path / 'test.txt'} && cat {tmp_path / 'test.txt'}",
            shell=True,
            cwd=tmp_path,
        )
        assert result.success
        assert "test content" in result.stdout

    def test_shell_mode_with_environment(self, runner: CmdRunner, tmp_path: Path):
        """shell=True works with custom environment."""
        result = runner.run(
            "echo $MY_VAR",
            shell=True,
            env={"MY_VAR": "custom_value"},
            cwd=tmp_path,
        )
        assert result.success
        assert "custom_value" in result.stdout

    @pytest.mark.parametrize("input_data", [None, "test input\n"])
    def test_shell_mode_with_input(self, runner: CmdRunner, input_data: str | None, tmp_path: Path):
        """shell=True works with and without input."""
        cmd = "cat" if input_data else "echo hello"
        result = runner.run(cmd, shell=True, input=input_data, cwd=tmp_path)
        assert result.success
        expected = input_data.strip() if input_data else "hello"
        assert expected in result.stdout


class TestCombinedFeatures:
    """Tests for valid combinations of features."""

    @pytest.mark.parametrize(
        "input_data,timeout,shell",
        [
            ("hello", None, False),
            ("hello", 5.0, False),
            (None, 5.0, True),
            ("hello", 5.0, True),
        ],
    )
    def test_valid_combinations(
        self,
        runner: CmdRunner,
        input_data: str | None,
        timeout: float | None,
        shell: bool,
        tmp_path: Path,
    ):
        """Test valid combinations of input, timeout, and shell."""
        cmd = "cat" if input_data else "echo test"
        result = runner.run(
            cmd if shell else list(cmd.split()),
            input=input_data,
            timeout=timeout,
            shell=shell,
            cwd=tmp_path,
        )
        assert result.success

    def test_input_with_show_output(self, runner: CmdRunner, tmp_path: Path):
        """input= works together with show_output=True."""
        result = runner.run(["cat"], input="streamed input", show_output=True, cwd=tmp_path)
        assert result.success
        assert "streamed input" in result.stdout

    def test_timeout_with_show_output(self, runner: CmdRunner, tmp_path: Path):
        """timeout works together with show_output=True."""
        with pytest.raises(CmdTimeout):
            runner.run(["sleep", "10"], timeout=0.2, show_output=True, cwd=tmp_path)

    def test_shell_with_cwd(self, runner: CmdRunner, tmp_path: Path):
        """shell=True respects cwd setting."""
        subdir = tmp_path / "subdir"
        subdir.mkdir()
        test_file = subdir / "marker.txt"
        test_file.write_text("marker content")

        result = runner.run("cat marker.txt", shell=True, cwd=subdir)
        assert result.success
        assert "marker content" in result.stdout

    def test_all_pipes_features(self, runner: CmdRunner, tmp_path: Path):
        """All pipes mode features work together."""
        result = runner.run(
            "cat",
            shell=True,
            input="test data",
            timeout=5.0,
            show_output=True,
            cwd=tmp_path,
        )
        assert result.success
        assert "test data" in result.stdout


@pytest.mark.skipif(not _HAS_UNIX_PTY, reason="Unix PTY required")
class TestPtyInputForwarding:
    """Tests for PTY input forwarding to interactive processes.

    These tests use the PTY pattern from docs/testing-tty-input.md
    to programmatically type input to interactive processes.
    """

    def _run_with_pty_input(
        self,
        cmd: list[str],
        input_text: str,
        cwd: Path,
        timeout: float = 10.0,
    ) -> tuple[int, str]:
        """Run command with programmatic input via PTY.

        Args:
            cmd: Command and arguments to run
            input_text: Text to type when process starts
            cwd: Working directory
            timeout: Maximum time to wait

        Returns:
            Tuple of (exit_code, output)
        """
        master_fd, slave_fd = pty.openpty()
        output_chunks: list[bytes] = []

        def type_input():
            """Type input to PTY after brief delay."""
            time.sleep(0.5)  # Wait for process to start and prompt
            try:
                for char in input_text:
                    os.write(master_fd, char.encode())
                    time.sleep(0.02)  # Small delay between chars
                os.write(master_fd, b"\n")  # Press Enter
            except OSError:
                pass  # FD might be closed if process exited

        # Start input thread
        input_thread = threading.Thread(target=type_input, daemon=True)
        input_thread.start()

        # Run command with PTY
        process = subprocess.Popen(
            cmd,
            stdin=slave_fd,
            stdout=slave_fd,
            stderr=slave_fd,
            cwd=cwd,
            start_new_session=True,
        )

        # Close slave FD in parent - child has it
        os.close(slave_fd)

        # Read output with timeout
        start_time = time.time()
        while process.poll() is None:
            if time.time() - start_time > timeout:
                process.terminate()
                process.wait()
                break

            ready, _, _ = select.select([master_fd], [], [], 0.1)
            if ready:
                try:
                    data = os.read(master_fd, 4096)
                    if data:
                        output_chunks.append(data)
                except OSError:
                    break

        # Wait for process to finish
        process.wait()

        # Drain remaining output
        for _ in range(10):  # Try a few times
            ready, _, _ = select.select([master_fd], [], [], 0.1)
            if not ready:
                break
            try:
                data = os.read(master_fd, 4096)
                if not data:
                    break
                output_chunks.append(data)
            except OSError:
                break

        os.close(master_fd)

        output = b"".join(output_chunks).decode(errors="replace")
        return process.returncode, output

    def test_pty_input_forwarding_simple(self, tmp_path: Path):
        """PTY forwards simple input to processes."""
        script = tmp_path / "read_input.py"
        script.write_text(
            """
import sys
response = input("Enter: ")
print(f"Got: {response}")
"""
        )

        returncode, output = self._run_with_pty_input(
            [sys.executable, str(script)], "hello", tmp_path
        )
        assert returncode == 0
        assert "Got: hello" in output

    def test_pty_input_forwarding_multiple_lines(self, tmp_path: Path):
        """PTY handles multiple input prompts."""
        script = tmp_path / "multi_input.py"
        script.write_text(
            """
import sys
name = input("Name: ")
age = input("Age: ")
print(f"User: {name}, {age}")
"""
        )

        # For multiple inputs, we need to handle them sequentially
        # This is a simplified test - just verifying first input works
        returncode, output = self._run_with_pty_input(
            [sys.executable, str(script)], "Alice", tmp_path
        )
        # Will fail on second input but proves first worked
        assert "Name:" in output

    def test_pty_input_with_bash_read(self, tmp_path: Path):
        """PTY works with bash read builtin."""
        script = tmp_path / "bash_read.sh"
        script.write_text(
            """#!/bin/bash
read -p "Enter value: " value
echo "Value is: $value"
"""
        )
        script.chmod(0o755)

        returncode, output = self._run_with_pty_input([str(script)], "test_value", tmp_path)
        assert returncode == 0
        assert "Value is: test_value" in output

    def test_pty_input_with_password_style(self, tmp_path: Path):
        """PTY works with password-style (non-echoing) input using stty."""
        # Use stty -echo approach instead of getpass (getpass reads from /dev/tty)
        script = tmp_path / "secret_read.sh"
        script.write_text(
            """#!/bin/bash
# Read password without echo
printf "Password: "
stty -echo
read password
stty echo
echo  # newline after password
echo "Length: ${#password}"
"""
        )
        script.chmod(0o755)

        returncode, output = self._run_with_pty_input([str(script)], "secret123", tmp_path)
        assert returncode == 0
        assert "Length: 9" in output


class TestErrorHandling:
    """Tests for error handling in cmd_runner."""

    def test_command_not_found(self, runner: CmdRunner, tmp_path: Path):
        """Non-existent command raises FileNotFoundError."""
        with pytest.raises(FileNotFoundError):
            runner.run(["nonexistent_command_12345"], cwd=tmp_path)

    def test_permission_denied(self, runner: CmdRunner, tmp_path: Path):
        """Non-executable file raises PermissionError."""
        script = tmp_path / "notexec.sh"
        script.write_text("#!/bin/bash\necho hello")
        # Don't make it executable

        with pytest.raises(PermissionError):
            runner.run([str(script)], cwd=tmp_path)

    def test_nonexistent_cwd(self, runner: CmdRunner, tmp_path: Path):
        """Non-existent cwd raises error."""
        nonexistent = tmp_path / "does_not_exist"
        with pytest.raises(Exception):  # Could be FileNotFoundError or similar
            runner.run(["echo", "hello"], cwd=nonexistent)

    def test_killed_process(self, runner: CmdRunner, tmp_path: Path):
        """Process killed by signal returns non-zero."""
        script = tmp_path / "trap_test.sh"
        script.write_text(
            """#!/bin/bash
trap 'exit 130' SIGTERM
sleep 10
"""
        )
        script.chmod(0o755)

        with pytest.raises(CmdTimeout):
            runner.run([str(script)], timeout=0.2, cwd=tmp_path)
