"""
test_security_hardening.py - Injection, path-traversal, and attack-vector tests.

These tests verify that no crafted input can:
  1. Bypass path guardrails via traversal or encoding tricks.
  2. Cause shell injection (watchdog/pathlib never shell-out, but we document it).
  3. Crash or silently corrupt the system through unexpected inputs.
  4. Use vulnerable third-party packages.

All tests that reach the live filesystem are clearly marked.
The slow dependency-audit test is marked @pytest.mark.slow.
"""

import json
import os
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch

import pytest

sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

import server  # noqa: E402
from security import SecurityError, validate_watch_request  # noqa: E402
from server import (  # noqa: E402
    list_active_watches,
    poll_events,
    unwatch,
    watch_directory,
)
from watcher import WatchManager  # noqa: E402


# ---------------------------------------------------------------------------
# Fixture: isolated server manager
# ---------------------------------------------------------------------------


@pytest.fixture(autouse=True)
def isolated_manager(monkeypatch, tmp_path):
    mgr = WatchManager()
    monkeypatch.setattr(server, "manager", mgr)
    yield mgr
    mgr.stop_all()


# ---------------------------------------------------------------------------
# 1. Path-traversal attacks on validate_watch_request
# ---------------------------------------------------------------------------


class TestPathTraversal:
    """
    validate_watch_request uses Path.resolve() which collapses all '..' segments
    before any comparison, so traversal attempts are neutralised at source.
    """

    def test_dotdot_traversal_to_windows_system32(self):
        """..\\..\\Windows\\System32 resolves into the blocked subtree."""
        path = "C:\\Users\\alice\\..\\..\\Windows\\System32"
        with pytest.raises(SecurityError):
            validate_watch_request(path)

    def test_dotdot_traversal_to_windows_root(self):
        """Multiple ..s climbing to C:\\Windows should be blocked."""
        path = "C:\\Users\\alice\\projects\\..\\..\\..\\Windows"
        with pytest.raises(SecurityError):
            validate_watch_request(path)

    def test_relative_dotdot_resolves_and_checked(self, tmp_path, monkeypatch):
        """
        A relative traversal is resolve()-d from cwd.  If it ends up at a
        non-existent or blocked location it raises SecurityError.
        """
        monkeypatch.chdir(tmp_path)
        # This resolves to tmp_path/../.. (two levels up) which almost certainly
        # either doesn't exist as navigated or is a system path.
        # We just verify no unhandled exception — any outcome is safe.
        try:
            validate_watch_request("../../some_dir_that_wont_exist")
        except SecurityError:
            pass  # expected
        except Exception as exc:
            pytest.fail(f"Unexpected exception type: {type(exc).__name__}: {exc}")

    def test_null_byte_in_path_does_not_escape(self):
        """Null bytes in a path string should not produce a valid directory."""
        path = "C:\\safe\x00evil"
        with pytest.raises(SecurityError):
            validate_watch_request(path)

    def test_very_long_path_does_not_crash(self):
        """A path exceeding Windows MAX_PATH (260 chars) should raise SecurityError."""
        long_segment = "a" * 300
        path = f"C:\\Users\\alice\\{long_segment}"
        with pytest.raises(SecurityError):
            validate_watch_request(path)

    def test_url_encoded_separators_are_literal(self, tmp_path):
        """
        %2F and %5C are URL-encoded slashes. pathlib treats them as literal
        characters, so they produce a non-existent path — SecurityError expected.
        """
        path = str(tmp_path) + "%2F..%2FWindows"
        with pytest.raises(SecurityError):
            validate_watch_request(path)

    def test_unicode_lookalike_drive_letter_does_not_bypass(self):
        """
        Cyrillic 'С' (U+0421) looks like Latin 'C' but is a different codepoint.
        The path won't exist → SecurityError.
        """
        cyrillic_c = "\u0421"  # Cyrillic capital С
        path = f"{cyrillic_c}:\\Windows\\System32"
        with pytest.raises(SecurityError):
            validate_watch_request(path)

    def test_symlink_to_blocked_path_is_blocked(self, tmp_path):
        """
        A symlink that points to C:\\Windows (or any blocked path) should be
        blocked after resolve() follows the symlink.
        """
        link = tmp_path / "evil_link"
        try:
            link.symlink_to("C:\\Windows", target_is_directory=True)
        except (OSError, NotImplementedError):
            pytest.skip("Cannot create directory symlink on this system without privileges")

        with pytest.raises(SecurityError):
            validate_watch_request(str(link))

    def test_forward_slashes_on_windows_resolved_correctly(self):
        """Windows accepts forward slashes; Path.resolve() normalises them."""
        path = "C:/Windows/System32"
        with pytest.raises(SecurityError):
            validate_watch_request(path)


# ---------------------------------------------------------------------------
# 2. Argument injection in server tools
# ---------------------------------------------------------------------------


class TestArgumentInjection:
    """
    Verify that crafted argument strings never trigger shell execution,
    crash the server, or silently do something unexpected.
    """

    def test_path_with_shell_metacharacters_windows(self, tmp_path):
        """
        A path like 'C:\\safe; calc.exe' is non-existent → SecurityError / failure.
        The key property: no subprocess is spawned.
        """
        evil_path = r"C:\safe; calc.exe"
        result = watch_directory(evil_path)
        # Must NOT succeed and must NOT spawn a process
        assert result["success"] is False

    def test_path_with_pipe_metacharacter(self):
        result = watch_directory("C:\\Windows | evil")
        assert result["success"] is False

    def test_event_types_with_command_injection_string(self, tmp_path):
        """event_types validation must reject anything not in the allowed set."""
        result = watch_directory(str(tmp_path), event_types=["created; echo pwned"])
        assert result["success"] is False
        assert "Invalid event_types" in result["error"]

    def test_event_types_with_sql_injection_string(self, tmp_path):
        result = watch_directory(str(tmp_path), event_types=["' OR 1=1 --"])
        assert result["success"] is False

    def test_extensions_with_null_byte_treated_as_literal(self, tmp_path):
        """
        An extension like '.py\x00.exe' is a literal string; no shell invocation.
        The watch starts successfully — the extension filter just won't match
        normal files (which is safe).
        """
        result = watch_directory(str(tmp_path), extensions=[".py\x00.exe"])
        # success or failure depends on whether the path is valid; the key
        # property is no crash and no privilege escalation.
        assert isinstance(result, dict)
        assert "success" in result

    def test_pattern_with_path_traversal_only_matches_filename(self, tmp_path):
        """
        pattern is matched against file *name* only via fnmatch, so
        '../../Windows' as a pattern can only match a filename literally — it
        cannot cause directory traversal.
        """
        result = watch_directory(str(tmp_path), pattern="../../Windows")
        # The watch itself is valid; the dangerous pattern is harmless.
        assert result["success"] is True

    def test_poll_events_sql_injection_watch_id_returns_empty(self):
        """watch_id is used as a plain dict key — no query execution."""
        result = poll_events(watch_id="' OR '1'='1")
        assert result["count"] == 0
        assert result["events"] == []

    def test_poll_events_script_tag_watch_id(self):
        result = poll_events(watch_id="<script>alert(1)</script>")
        assert result["count"] == 0

    def test_unwatch_sql_injection_returns_failure(self):
        result = unwatch("'; DROP TABLE watches; --")
        assert result["success"] is False

    def test_unwatch_empty_string_returns_failure(self):
        result = unwatch("")
        assert result["success"] is False

    def test_unwatch_very_long_id_returns_failure(self):
        result = unwatch("x" * 10_000)
        assert result["success"] is False

    def test_watch_directory_empty_string_path_returns_failure(self):
        result = watch_directory("")
        assert result["success"] is False

    def test_watch_directory_whitespace_path_returns_failure(self):
        result = watch_directory("   ")
        assert result["success"] is False


# ---------------------------------------------------------------------------
# 3. Dependency vulnerability audit
# ---------------------------------------------------------------------------


@pytest.mark.slow
class TestDependencyVulnerabilities:
    """
    Runs pip-audit against the current environment and asserts that no
    known vulnerabilities are reported.

    Mark: `slow` — skip during rapid development with `pytest -m "not slow"`.
    """

    def test_no_vulnerable_packages(self):
        """pip-audit must report zero vulnerabilities."""
        pip_audit = Path(sys.executable).parent / "pip-audit"
        if not pip_audit.exists():
            pip_audit = Path(sys.executable).parent / "pip-audit.exe"
        if not pip_audit.exists():
            pytest.skip("pip-audit not found in virtualenv — run `uv sync` first")

        result = subprocess.run(
            [str(pip_audit), "--format=json", "--progress-spinner=off"],
            capture_output=True,
            text=True,
        )

        try:
            report = json.loads(result.stdout)
        except json.JSONDecodeError:
            pytest.fail(
                f"pip-audit produced non-JSON output.\n"
                f"stdout: {result.stdout[:500]}\n"
                f"stderr: {result.stderr[:500]}"
            )

        # pip-audit JSON schema: {"dependencies": [...], "fixes": [...]}
        # Each dependency entry may have "vulns": [] or "skip_reason": "..."
        dependencies = report.get("dependencies", [])
        vulnerable = [
            pkg
            for pkg in dependencies
            if pkg.get("vulns")  # non-empty vulns list means known CVEs
        ]

        if vulnerable:
            details = "\n".join(
                f"  {pkg['name']}=={pkg.get('version', '?')}: "
                + ", ".join(v["id"] for v in pkg["vulns"])
                for pkg in vulnerable
            )
            pytest.fail(
                f"Vulnerable packages detected by pip-audit:\n{details}\n"
                "Run `pip-audit --fix` or update the affected packages."
            )
